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 mkdir -p /tmp/translation-deps
cd /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 npm install --no-package-lock
# 设置 NODE_PATH 让项目能找到这些依赖 # 设置 NODE_PATH 让项目能找到这些依赖
@ -45,7 +45,7 @@ jobs:
run: npx tsx scripts/auto-translate-i18n.ts run: npx tsx scripts/auto-translate-i18n.ts
- name: 🔍 Format - 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 - name: 🔄 Commit changes
run: | run: |

View File

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

1
.gitignore vendored
View File

@ -37,6 +37,7 @@ dist
out out
mcp_server mcp_server
stats.html stats.html
.eslintcache
# ENV # ENV
.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": [ "recommendations": [
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"editorconfig.editorconfig", "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]": { "[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "biomejs.biome"
}, },
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "biomejs.biome"
}, },
"[json]": { "[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "biomejs.biome"
}, },
"[jsonc]": { "[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "biomejs.biome"
}, },
"[markdown]": { "[markdown]": {
"files.trimTrailingWhitespace": false "files.trimTrailingWhitespace": false
}, },
"[scss]": { "[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "biomejs.biome"
}, },
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "biomejs.biome"
}, },
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "biomejs.biome"
}, },
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.fixAll.eslint": "explicit", "source.fixAll.eslint": "explicit",
"source.fixAll.oxc": "explicit",
"source.organizeImports": "never" "source.organizeImports": "never"
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true,
@ -45,5 +47,6 @@
"search.exclude": { "search.exclude": {
"**/dist/**": true, "**/dist/**": true,
".yarn/releases/**": true ".yarn/releases/**": true
} },
"typescript.experimental.useTsgo": true
} }

View File

@ -5,3 +5,5 @@ httpTimeout: 300000
nodeLinker: node-modules nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.1.cjs 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 ## 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 ## Project Setup

View File

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

View File

@ -4,7 +4,9 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import { resolve } from 'path' import { resolve } from 'path'
import { visualizer } from 'rollup-plugin-visualizer' 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') => { const visualizerPlugin = (type: 'renderer' | 'main') => {
return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : [] 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 tseslint from '@electron-toolkit/eslint-config-ts'
import eslint from '@eslint/js' import eslint from '@eslint/js'
import eslintReact from '@eslint-react/eslint-plugin' import eslintReact from '@eslint-react/eslint-plugin'
import { defineConfig } from 'eslint/config' import { defineConfig } from 'eslint/config'
import oxlint from 'eslint-plugin-oxlint'
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from 'eslint-plugin-react-hooks'
import simpleImportSort from 'eslint-plugin-simple-import-sort' import simpleImportSort from 'eslint-plugin-simple-import-sort'
import unusedImports from 'eslint-plugin-unused-imports' import unusedImports from 'eslint-plugin-unused-imports'
@ -10,7 +10,6 @@ import unusedImports from 'eslint-plugin-unused-imports'
export default defineConfig([ export default defineConfig([
eslint.configs.recommended, eslint.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
electronConfigPrettier,
eslintReact.configs['recommended-typescript'], eslintReact.configs['recommended-typescript'],
reactHooks.configs['recommended-latest'], reactHooks.configs['recommended-latest'],
{ {
@ -26,7 +25,6 @@ export default defineConfig([
'simple-import-sort/exports': 'error', 'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-imports': 'error',
'@eslint-react/no-prop-types': 'error', '@eslint-react/no-prop-types': 'error',
'prettier/prettier': ['error']
} }
}, },
// Configuration for ensuring compatibility with the original ESLint(8.x) rules // 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 // LoggerService Custom Rules - only apply to src directory
files: ['src/**/*.{ts,tsx,js,jsx}'], files: ['src/**/*.{ts,tsx,js,jsx}'],
ignores: ['src/**/__tests__/**', 'src/**/__mocks__/**', 'src/**/*.test.*'], ignores: ['src/**/__tests__/**', 'src/**/__mocks__/**', 'src/**/*.test.*', 'src/preload/**'],
rules: { rules: {
'no-restricted-syntax': [ 'no-restricted-syntax': [
process.env.PRCI ? 'error' : 'warn', process.env.PRCI ? 'error' : 'warn',
@ -128,5 +126,9 @@ export default defineConfig([
'src/renderer/src/ui/**', 'src/renderer/src/ui/**',
'packages/**/dist' '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:renderer": "VISUALIZER_RENDERER=true yarn build",
"analyze:main": "VISUALIZER_MAIN=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": "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:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
"check:i18n": "tsx scripts/check-i18n.ts", "check:i18n": "tsx scripts/check-i18n.ts",
"sync:i18n": "tsx scripts/sync-i18n.ts", "sync:i18n": "tsx scripts/sync-i18n.ts",
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts", "update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
@ -67,13 +67,16 @@
"test:ui": "vitest --ui", "test:ui": "vitest --ui",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "yarn playwright test", "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", "test:scripts": "vitest scripts",
"format": "prettier --write .", "lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn check:i18n",
"format:check": "prettier --check .", "format": "biome format --write && biome lint --write",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix && yarn typecheck && yarn check:i18n", "format:check": "biome format && biome lint",
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky", "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": { "dependencies": {
"@anthropic-ai/claude-code": "^1.0.113", "@anthropic-ai/claude-code": "^1.0.113",
@ -85,7 +88,6 @@
"drizzle-orm": "^0.44.5", "drizzle-orm": "^0.44.5",
"express": "^5.1.0", "express": "^5.1.0",
"express-validator": "^7.2.1", "express-validator": "^7.2.1",
"faiss-node": "^0.5.1",
"font-list": "^2.0.0", "font-list": "^2.0.0",
"graceful-fs": "^4.2.11", "graceful-fs": "^4.2.11",
"jsdom": "26.1.0", "jsdom": "26.1.0",
@ -103,17 +105,18 @@
"@agentic/exa": "^7.3.3", "@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3", "@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3", "@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.0", "@ai-sdk/amazon-bedrock": "^3.0.21",
"@ai-sdk/google-vertex": "^3.0.25", "@ai-sdk/google-vertex": "^3.0.27",
"@ai-sdk/mistral": "^2.0.0", "@ai-sdk/mistral": "^2.0.14",
"@ai-sdk/perplexity": "^2.0.8", "@ai-sdk/perplexity": "^2.0.9",
"@ant-design/v5-patch-for-react-19": "^1.0.3", "@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0", "@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", "@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": "^3.840.0",
"@aws-sdk/client-bedrock-runtime": "^3.840.0", "@aws-sdk/client-bedrock-runtime": "^3.840.0",
"@aws-sdk/client-s3": "^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": "^0.1.31",
"@cherrystudio/embedjs-libsql": "^0.1.31", "@cherrystudio/embedjs-libsql": "^0.1.31",
"@cherrystudio/embedjs-loader-csv": "^0.1.31", "@cherrystudio/embedjs-loader-csv": "^0.1.31",
@ -131,7 +134,6 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
@ -145,9 +147,6 @@
"@heroui/react": "^2.8.3", "@heroui/react": "^2.8.3",
"@kangfenmao/keyv-storage": "^0.1.0", "@kangfenmao/keyv-storage": "^0.1.0",
"@langchain/community": "^0.3.50", "@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", "@mistralai/mistralai": "^1.7.5",
"@modelcontextprotocol/sdk": "^1.17.5", "@modelcontextprotocol/sdk": "^1.17.5",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
@ -213,6 +212,7 @@
"@types/tinycolor2": "^1", "@types/tinycolor2": "^1",
"@types/turndown": "^5.0.5", "@types/turndown": "^5.0.5",
"@types/word-extractor": "^1", "@types/word-extractor": "^1",
"@typescript/native-preview": "latest",
"@uiw/codemirror-extensions-langs": "^4.25.1", "@uiw/codemirror-extensions-langs": "^4.25.1",
"@uiw/codemirror-themes-all": "^4.25.1", "@uiw/codemirror-themes-all": "^4.25.1",
"@uiw/react-codemirror": "^4.25.1", "@uiw/react-codemirror": "^4.25.1",
@ -224,7 +224,7 @@
"@viz-js/lang-dot": "^1.0.5", "@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0", "@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4", "@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", "antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"async-mutex": "^0.5.0", "async-mutex": "^0.5.0",
@ -246,6 +246,7 @@
"diff": "^8.0.2", "diff": "^8.0.2",
"docx": "^9.0.2", "docx": "^9.0.2",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"dotenv": "^17.2.2",
"dotenv-cli": "^7.4.2", "dotenv-cli": "^7.4.2",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"electron": "37.4.0", "electron": "37.4.0",
@ -260,6 +261,7 @@
"emoji-picker-element": "^1.22.1", "emoji-picker-element": "^1.22.1",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch", "epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"eslint": "^9.22.0", "eslint": "^9.22.0",
"eslint-plugin-oxlint": "^1.15.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
@ -295,13 +297,12 @@
"notion-helper": "^1.3.22", "notion-helper": "^1.3.22",
"npx-scope-finder": "^1.2.0", "npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch", "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", "p-queue": "^8.1.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"playwright": "^1.52.0", "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", "proxy-agent": "^6.5.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@ -384,11 +385,11 @@
"packageManager": "yarn@4.9.1", "packageManager": "yarn@4.9.1",
"lint-staged": { "lint-staged": {
"*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [ "*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [
"prettier --write", "biome format --write",
"eslint --fix" "eslint --fix"
], ],
"*.{json,yml,yaml,css,html}": [ "*.{json,yml,yaml,css,html}": [
"prettier --write" "biome format --write"
] ]
} }
} }

View File

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

View File

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

View File

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

View File

@ -67,13 +67,13 @@
"dist" "dist"
], ],
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.4",
"@tiptap/core": "^3.2.0", "@tiptap/core": "^3.2.0",
"@tiptap/pm": "^3.2.0", "@tiptap/pm": "^3.2.0",
"eslint": "^9.22.0", "eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"prettier": "^3.5.3",
"tsdown": "^0.13.3" "tsdown": "^0.13.3"
}, },
"peerDependencies": { "peerDependencies": {
@ -87,7 +87,7 @@
}, },
"scripts": { "scripts": {
"build": "tsdown", "build": "tsdown",
"lint": "prettier ./src/ --write && eslint --fix ./src/" "lint": "biome format ./src/ --write && eslint --fix ./src/"
}, },
"packageManager": "yarn@4.9.1" "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. * @param filePath The path to the file to format.
*/ */
async function formatWithPrettier(filePath: string): Promise<void> { async function format(filePath: string): Promise<void> {
console.log('🎨 Formatting file with Prettier...') console.log('🎨 Formatting file with Biome...')
try { try {
await execAsync(`yarn prettier --write ${filePath}`) await execAsync(`yarn biome format --write ${filePath}`)
console.log('✅ Prettier formatting complete.') console.log('✅ Biome formatting complete.')
} catch (e: any) { } catch (e: any) {
console.error('❌ Prettier formatting failed:', e.stdout || e.stderr) console.error('❌ Biome formatting failed:', e.stdout || e.stderr)
throw new Error('Prettier formatting failed.') throw new Error('Biome formatting failed.')
} }
} }
@ -116,7 +116,7 @@ async function updateLanguagesFile(): Promise<void> {
await fs.writeFile(LANGUAGES_FILE_PATH, fileContent, 'utf-8') await fs.writeFile(LANGUAGES_FILE_PATH, fileContent, 'utf-8')
console.log(`✅ Successfully wrote to ${LANGUAGES_FILE_PATH}`) console.log(`✅ Successfully wrote to ${LANGUAGES_FILE_PATH}`)
await formatWithPrettier(LANGUAGES_FILE_PATH) await format(LANGUAGES_FILE_PATH)
await checkTypeScript(LANGUAGES_FILE_PATH) await checkTypeScript(LANGUAGES_FILE_PATH)
console.log('🎉 Successfully updated languages.ts file!') console.log('🎉 Successfully updated languages.ts file!')

View File

@ -4,7 +4,7 @@ import { loggerService } from '../../services/LoggerService'
const logger = loggerService.withContext('ApiServerErrorHandler') 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) => { export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => {
logger.error('API Server Error:', err) 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 { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant' import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel' 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 checkDiskSpace from 'check-disk-space'
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron' import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
import fontList from 'font-list' import fontList from 'font-list'
import { Notification } from 'src/renderer/src/types/notification'
import { apiServerService } from './services/ApiServerService' import { apiServerService } from './services/ApiServerService'
import appService from './services/AppService' import appService from './services/AppService'
@ -28,7 +27,7 @@ import DxtService from './services/DxtService'
import { ExportService } from './services/ExportService' import { ExportService } from './services/ExportService'
import { fileStorage as fileManager } from './services/FileStorage' import { fileStorage as fileManager } from './services/FileStorage'
import FileService from './services/FileSystemService' import FileService from './services/FileSystemService'
import KnowledgeService from './services/knowledge/KnowledgeService' import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService' import mcpService from './services/MCPService'
import MemoryService from './services/memory/MemoryService' import MemoryService from './services/memory/MemoryService'
import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceService' 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) ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
// OCR // 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 // CherryIN
ipcMain.handle(IpcChannel.Cherryin_GetSignature, (_, params) => generateSignature(params)) ipcMain.handle(IpcChannel.Cherryin_GetSignature, (_, params) => generateSignature(params))

View File

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

View File

@ -11,7 +11,7 @@ export enum OdType {
OdtLoader = 'OdtLoader', OdtLoader = 'OdtLoader',
OdsLoader = 'OdsLoader', OdsLoader = 'OdsLoader',
OdpLoader = 'OdpLoader', OdpLoader = 'OdpLoader',
undefined = 'undefined' Undefined = 'undefined'
} }
export class OdLoader<OdType> extends BaseLoader<{ type: string }> { 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 // Build command to execute
let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"` 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') const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
if (isInstalled) { if (isInstalled) {

View File

@ -1,4 +1,4 @@
/* eslint-disable no-case-declarations */ /* oxlint-disable no-case-declarations */
// ExportService // ExportService
import { loggerService } from '@logger' 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 * as fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
@ -9,32 +24,87 @@ import { loggerService } from '@logger'
import Embeddings from '@main/knowledge/embedjs/embeddings/Embeddings' import Embeddings from '@main/knowledge/embedjs/embeddings/Embeddings'
import { addFileLoader } from '@main/knowledge/embedjs/loader' import { addFileLoader } from '@main/knowledge/embedjs/loader'
import { NoteLoader } from '@main/knowledge/embedjs/loader/noteLoader' 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 { getAllFiles } from '@main/utils/file'
import { TraceMethod } from '@mcp-trace/trace-core'
import { MB } from '@shared/config/constant' 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 { IpcChannel } from '@shared/IpcChannel'
import { FileMetadata, KnowledgeBaseParams, KnowledgeSearchResult } from '@types' import { FileMetadata, KnowledgeBaseParams, KnowledgeItem, KnowledgeSearchResult } from '@types'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { windowService } from '../WindowService'
import {
IKnowledgeFramework,
KnowledgeBaseAddItemOptionsNonNullableAttribute,
LoaderDoneReturn,
LoaderTask,
LoaderTaskItem,
LoaderTaskItemState
} from './IKnowledgeFramework'
const logger = loggerService.withContext('MainKnowledgeService') const logger = loggerService.withContext('MainKnowledgeService')
export class EmbedJsFramework implements IKnowledgeFramework { export interface KnowledgeBaseAddItemOptions {
private storageDir: string base: KnowledgeBaseParams
private ragApplications: Map<string, RAGApplication> = new Map() item: KnowledgeItem
private pendingDeleteFile: string forceReload?: boolean
private dbInstances: Map<string, LibSqlDb> = new Map() 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 = { private static ERROR_LOADER_RETURN: LoaderReturn = {
entriesAdded: 0, entriesAdded: 0,
uniqueId: '', uniqueId: '',
@ -43,9 +113,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
status: 'failed' status: 'failed'
} }
constructor(storageDir: string) { constructor() {
this.storageDir = storageDir
this.pendingDeleteFile = path.join(this.storageDir, 'knowledge_pending_delete.json')
this.initStorageDir() this.initStorageDir()
this.cleanupOnStartup() this.cleanupOnStartup()
} }
@ -160,28 +228,33 @@ export class EmbedJsFramework implements IKnowledgeFramework {
logger.info(`Startup cleanup completed: ${deletedCount}/${pendingDeleteIds.length} knowledge bases deleted`) logger.info(`Startup cleanup completed: ${deletedCount}/${pendingDeleteIds.length} knowledge bases deleted`)
} }
private async getRagApplication(base: KnowledgeBaseParams): Promise<RAGApplication> { private getRagApplication = async ({
if (this.ragApplications.has(base.id)) { id,
return this.ragApplications.get(base.id)! embedApiClient,
dimensions,
documentCount
}: KnowledgeBaseParams): Promise<RAGApplication> => {
if (this.ragApplications.has(id)) {
return this.ragApplications.get(id)!
} }
let ragApplication: RAGApplication let ragApplication: RAGApplication
const embeddings = new Embeddings({ const embeddings = new Embeddings({
embedApiClient: base.embedApiClient, embedApiClient,
dimensions: base.dimensions dimensions
}) })
try { 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 // Save database instance for later closing
this.dbInstances.set(base.id, libSqlDb) this.dbInstances.set(id, libSqlDb)
ragApplication = await new RAGApplicationBuilder() ragApplication = await new RAGApplicationBuilder()
.setModel('NO_MODEL') .setModel('NO_MODEL')
.setEmbeddingModel(embeddings) .setEmbeddingModel(embeddings)
.setVectorDatabase(libSqlDb) .setVectorDatabase(libSqlDb)
.setSearchResultCount(base.documentCount || 30) .setSearchResultCount(documentCount || 30)
.build() .build()
this.ragApplications.set(base.id, ragApplication) this.ragApplications.set(id, ragApplication)
} catch (e) { } catch (e) {
logger.error('Failed to create RAGApplication:', e as Error) logger.error('Failed to create RAGApplication:', e as Error)
throw new Error(`Failed to create RAGApplication: ${e}`) throw new Error(`Failed to create RAGApplication: ${e}`)
@ -189,14 +262,17 @@ export class EmbedJsFramework implements IKnowledgeFramework {
return ragApplication return ragApplication
} }
async initialize(base: KnowledgeBaseParams): Promise<void> {
public create = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
await this.getRagApplication(base) await this.getRagApplication(base)
} }
async reset(base: KnowledgeBaseParams): Promise<void> {
const ragApp = await this.getRagApplication(base) public reset = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
await ragApp.reset() 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}`) logger.debug(`delete id: ${id}`)
await this.cleanupKnowledgeResources(id) await this.cleanupKnowledgeResources(id)
@ -209,41 +285,15 @@ export class EmbedJsFramework implements IKnowledgeFramework {
this.pendingDeleteManager.add(id) 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> { private maximumLoad() {
const ragApp = await this.getRagApplication(options.base) return (
for (const id of options.uniqueIds) { this.processingItemCount >= KnowledgeService.MAXIMUM_PROCESSING_ITEM_COUNT ||
await ragApp.deleteLoader(id) 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( private fileTask(
getRagApplication: () => Promise<RAGApplication>, ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask { ): LoaderTask {
const { base, item, forceReload, userId } = options const { base, item, forceReload, userId } = options
@ -256,8 +306,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
task: async () => { task: async () => {
try { try {
// Add preprocessing logic // Add preprocessing logic
const ragApplication = await getRagApplication() const fileToProcess: FileMetadata = await this.preprocessing(file, base, item, userId)
const fileToProcess: FileMetadata = await preprocessingService.preprocessFile(file, base, item, userId)
// Use processed file for loading // Use processed file for loading
return addFileLoader(ragApplication, fileToProcess, base, forceReload) return addFileLoader(ragApplication, fileToProcess, base, forceReload)
@ -268,7 +317,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
.catch((e) => { .catch((e) => {
logger.error(`Error in addFileLoader for ${file.name}: ${e}`) logger.error(`Error in addFileLoader for ${file.name}: ${e}`)
const errorResult: LoaderReturn = { const errorResult: LoaderReturn = {
...EmbedJsFramework.ERROR_LOADER_RETURN, ...KnowledgeService.ERROR_LOADER_RETURN,
message: e.message, message: e.message,
messageSource: 'embedding' messageSource: 'embedding'
} }
@ -278,7 +327,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
} catch (e: any) { } catch (e: any) {
logger.error(`Preprocessing failed for ${file.name}: ${e}`) logger.error(`Preprocessing failed for ${file.name}: ${e}`)
const errorResult: LoaderReturn = { const errorResult: LoaderReturn = {
...EmbedJsFramework.ERROR_LOADER_RETURN, ...KnowledgeService.ERROR_LOADER_RETURN,
message: e.message, message: e.message,
messageSource: 'preprocess' messageSource: 'preprocess'
} }
@ -295,7 +344,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
return loaderTask return loaderTask
} }
private directoryTask( private directoryTask(
getRagApplication: () => Promise<RAGApplication>, ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask { ): LoaderTask {
const { base, item, forceReload } = options const { base, item, forceReload } = options
@ -322,9 +371,8 @@ export class EmbedJsFramework implements IKnowledgeFramework {
for (const file of files) { for (const file of files) {
loaderTasks.push({ loaderTasks.push({
state: LoaderTaskItemState.PENDING, state: LoaderTaskItemState.PENDING,
task: async () => { task: () =>
const ragApplication = await getRagApplication() addFileLoader(ragApplication, file, base, forceReload)
return addFileLoader(ragApplication, file, base, forceReload)
.then((result) => { .then((result) => {
loaderDoneReturn.entriesAdded += 1 loaderDoneReturn.entriesAdded += 1
processedFiles += 1 processedFiles += 1
@ -335,12 +383,11 @@ export class EmbedJsFramework implements IKnowledgeFramework {
.catch((err) => { .catch((err) => {
logger.error('Failed to add dir loader:', err) logger.error('Failed to add dir loader:', err)
return { return {
...EmbedJsFramework.ERROR_LOADER_RETURN, ...KnowledgeService.ERROR_LOADER_RETURN,
message: `Failed to add dir loader: ${err.message}`, message: `Failed to add dir loader: ${err.message}`,
messageSource: 'embedding' messageSource: 'embedding'
} }
}) }),
},
evaluateTaskWorkload: { workload: file.size } evaluateTaskWorkload: { workload: file.size }
}) })
} }
@ -352,7 +399,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
} }
private urlTask( private urlTask(
getRagApplication: () => Promise<RAGApplication>, ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask { ): LoaderTask {
const { base, item, forceReload } = options const { base, item, forceReload } = options
@ -362,8 +409,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
loaderTasks: [ loaderTasks: [
{ {
state: LoaderTaskItemState.PENDING, state: LoaderTaskItemState.PENDING,
task: async () => { task: () => {
const ragApplication = await getRagApplication()
const loaderReturn = ragApplication.addLoader( const loaderReturn = ragApplication.addLoader(
new WebLoader({ new WebLoader({
urlOrContent: content, urlOrContent: content,
@ -387,7 +433,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
.catch((err) => { .catch((err) => {
logger.error('Failed to add url loader:', err) logger.error('Failed to add url loader:', err)
return { return {
...EmbedJsFramework.ERROR_LOADER_RETURN, ...KnowledgeService.ERROR_LOADER_RETURN,
message: `Failed to add url loader: ${err.message}`, message: `Failed to add url loader: ${err.message}`,
messageSource: 'embedding' messageSource: 'embedding'
} }
@ -402,7 +448,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
} }
private sitemapTask( private sitemapTask(
getRagApplication: () => Promise<RAGApplication>, ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask { ): LoaderTask {
const { base, item, forceReload } = options const { base, item, forceReload } = options
@ -412,9 +458,8 @@ export class EmbedJsFramework implements IKnowledgeFramework {
loaderTasks: [ loaderTasks: [
{ {
state: LoaderTaskItemState.PENDING, state: LoaderTaskItemState.PENDING,
task: async () => { task: () =>
const ragApplication = await getRagApplication() ragApplication
return ragApplication
.addLoader( .addLoader(
new SitemapLoader({ url: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }) as any, new SitemapLoader({ url: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }) as any,
forceReload forceReload
@ -432,12 +477,11 @@ export class EmbedJsFramework implements IKnowledgeFramework {
.catch((err) => { .catch((err) => {
logger.error('Failed to add sitemap loader:', err) logger.error('Failed to add sitemap loader:', err)
return { return {
...EmbedJsFramework.ERROR_LOADER_RETURN, ...KnowledgeService.ERROR_LOADER_RETURN,
message: `Failed to add sitemap loader: ${err.message}`, message: `Failed to add sitemap loader: ${err.message}`,
messageSource: 'embedding' messageSource: 'embedding'
} }
}) }),
},
evaluateTaskWorkload: { workload: 20 * MB } evaluateTaskWorkload: { workload: 20 * MB }
} }
], ],
@ -447,7 +491,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
} }
private noteTask( private noteTask(
getRagApplication: () => Promise<RAGApplication>, ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask { ): LoaderTask {
const { base, item, forceReload } = options const { base, item, forceReload } = options
@ -460,8 +504,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
loaderTasks: [ loaderTasks: [
{ {
state: LoaderTaskItemState.PENDING, state: LoaderTaskItemState.PENDING,
task: async () => { task: () => {
const ragApplication = await getRagApplication()
const loaderReturn = ragApplication.addLoader( const loaderReturn = ragApplication.addLoader(
new NoteLoader({ new NoteLoader({
text: content, text: content,
@ -484,7 +527,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
.catch((err) => { .catch((err) => {
logger.error('Failed to add note loader:', err) logger.error('Failed to add note loader:', err)
return { return {
...EmbedJsFramework.ERROR_LOADER_RETURN, ...KnowledgeService.ERROR_LOADER_RETURN,
message: `Failed to add note loader: ${err.message}`, message: `Failed to add note loader: ${err.message}`,
messageSource: 'embedding' messageSource: 'embedding'
} }
@ -497,4 +540,199 @@ export class EmbedJsFramework implements IKnowledgeFramework {
} }
return loaderTask 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 { try {
await inMemoryServer.connect(serverTransport) await inMemoryServer.connect(serverTransport)
getServerLogger(server).debug(`In-memory server started`) 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) getServerLogger(server).error(`Error starting in-memory server`, error as Error)
throw new Error(`Failed to start in-memory server: ${error.message}`) throw new Error(`Failed to start in-memory server: ${error.message}`)
} }
@ -419,7 +419,7 @@ class McpService {
const transport = await initTransport() const transport = await initTransport()
try { try {
await client.connect(transport) await client.connect(transport)
} catch (error: Error | any) { } catch (error: any) {
if ( if (
error instanceof Error && error instanceof Error &&
(error.name === 'UnauthorizedError' || error.message.includes('Unauthorized')) (error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))
@ -852,7 +852,7 @@ class McpService {
return { return {
contents: contents contents: contents
} }
} catch (error: Error | any) { } catch (error: any) {
getServerLogger(server, { uri }).error(`Failed to get resource`, error as Error) 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}`) 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 static instance: MistralClientManager
private client: Mistral | null = null 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() {} private constructor() {}
public static getInstance(): MistralClientManager { public static getInstance(): MistralClientManager {

View File

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

View File

@ -235,7 +235,7 @@ export class ProxyManager {
https.request = this.bindHttpMethod(this.originalHttpsRequest, agent) 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) { private bindHttpMethod(originalMethod: Function, agent: http.Agent | https.Agent) {
return (...args: any[]) => { return (...args: any[]) => {
let url: string | URL | undefined 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 { isLinux, isWin } from '@main/constant'
import { loadOcrImage } from '@main/utils/ocr' import { loadOcrImage } from '@main/utils/ocr'
import { OcrAccuracy, recognize } from '@napi-rs/system-ocr' import { OcrAccuracy, recognize } from '@napi-rs/system-ocr'
import { import { ImageFileMetadata, isImageFileMetadata, OcrResult, OcrSystemConfig, SupportedOcrFile } from '@types'
ImageFileMetadata,
isImageFileMetadata as isImageFileMetadata,
OcrResult,
OcrSystemConfig,
SupportedOcrFile
} from '@types'
import { OcrBaseService } from './OcrBaseService' import { OcrBaseService } from './OcrBaseService'

View File

@ -9,7 +9,7 @@ export class FileServiceManager {
private static instance: FileServiceManager private static instance: FileServiceManager
private services: Map<string, BaseFileService> = new Map() 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() {} private constructor() {}
static getInstance(): FileServiceManager { static getInstance(): FileServiceManager {

View File

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

View File

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

View File

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

View File

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

View File

@ -314,7 +314,7 @@ export class AiSdkToChunkAdapter {
// === 源和文件相关事件 === // === 源和文件相关事件 ===
case 'source': case 'source':
if (chunk.sourceType === 'url') { 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 const { sourceType: _, ...rest } = chunk
final.webSearchResults.push(rest) final.webSearchResults.push(rest)
} }

View File

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

View File

@ -177,7 +177,7 @@ export class AnthropicAPIClient extends BaseApiClient<
} }
// @ts-ignore sdk未提供 // @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[]> { override async generateImage(generateImageParams: GenerateImageParams): Promise<string[]> {
return [] return []
} }

View File

@ -455,7 +455,7 @@ export class AwsBedrockAPIClient extends BaseApiClient<
} }
// @ts-ignore sdk未提供 // @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[]> { override async generateImage(_generateImageParams: GenerateImageParams): Promise<string[]> {
return [] return []
} }

View File

@ -11,7 +11,7 @@ export class PPIOAPIClient extends OpenAIAPIClient {
super(provider) 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[] { override getClientCompatibilityType(_model?: Model): string[] {
return ['OpenAIAPIClient'] return ['OpenAIAPIClient']
} }

View File

@ -44,7 +44,7 @@ const stringifyArgsForLogging = (args: any[]): string => {
*/ */
export const createGenericLoggingMiddleware: () => MethodMiddleware = () => { export const createGenericLoggingMiddleware: () => MethodMiddleware = () => {
const middlewareName = 'GenericLoggingMiddleware' 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) => { return (_: MiddlewareAPI<BaseContext, any[]>) => (next) => async (ctx, args) => {
const methodName = ctx.methodName const methodName = ctx.methodName
const logPrefix = `[${middlewareName} (${methodName})]` const logPrefix = `[${middlewareName} (${methodName})]`

View File

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

View File

@ -18,7 +18,7 @@ import {
import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService' import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService'
import { type Assistant, type MCPTool, type Provider } from '@renderer/types' import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes' import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import type { ModelMessage } from 'ai' import type { ModelMessage, Tool } from 'ai'
import { stepCountIs } from 'ai' import { stepCountIs } from 'ai'
import { getAiSdkProviderId } from '../provider/factory' import { getAiSdkProviderId } from '../provider/factory'
@ -29,6 +29,8 @@ import { getTemperature, getTopP } from './modelParameters'
const logger = loggerService.withContext('parameterBuilder') const logger = loggerService.withContext('parameterBuilder')
type ProviderDefinedTool = Extract<Tool<any, any>, { type: 'provider-defined' }>
/** /**
* AI SDK * AI SDK
* *
@ -113,9 +115,9 @@ export async function buildStreamTextParams(
tools = {} tools = {}
} }
if (aiSdkProviderId === 'google-vertex') { if (aiSdkProviderId === 'google-vertex') {
tools.google_search = vertex.tools.googleSearch({}) tools.google_search = vertex.tools.googleSearch({}) as ProviderDefinedTool
} else if (aiSdkProviderId === 'google-vertex-anthropic') { } 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) { if (!tools) {
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') const logger = loggerService.withContext('MCP-utils')
// Setup tools configuration based on provided parameters // 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 = {} let tools: ToolSet = {}
if (!mcpTools?.length) { if (!mcpTools?.length) {

View File

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

View File

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

View File

@ -53,6 +53,7 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32); --sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067); --sidebar-ring: oklch(0.705 0.015 286.067);
--icon: #00000099;
} }
.dark { .dark {
@ -87,6 +88,7 @@
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938); --sidebar-ring: oklch(0.552 0.016 285.938);
--icon: #ffffff99;
} }
@theme inline { @theme inline {
@ -128,6 +130,7 @@
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--animate-marquee: marquee var(--duration) infinite linear; --animate-marquee: marquee var(--duration) infinite linear;
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite; --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
--color-icon: var(--icon);
@keyframes marquee { @keyframes marquee {
from { from {
transform: translateX(0); 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 NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
import { debounce } from 'lodash' import { debounce } from 'lodash'
@ -364,23 +364,23 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
<ToolBar> <ToolBar>
{showUserToggle && ( {showUserToggle && (
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom"> <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)' }} /> <User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton> </ActionIconButton>
</Tooltip> </Tooltip>
)} )}
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom"> <Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}> <ActionIconButton onClick={caseSensitiveButtonOnClick}>
<CaseSensitive <CaseSensitive
size={18} size={18}
style={{ color: isCaseSensitive ? 'var(--color-link)' : 'var(--color-icon)' }} style={{ color: isCaseSensitive ? 'var(--color-link)' : 'var(--color-icon)' }}
/> />
</ToolbarButton> </ActionIconButton>
</Tooltip> </Tooltip>
<Tooltip title={t('button.whole_word')} mouseEnterDelay={0.8} placement="bottom"> <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)' }} /> <WholeWord size={18} style={{ color: isWholeWord ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton> </ActionIconButton>
</Tooltip> </Tooltip>
</ToolBar> </ToolBar>
</InputWrapper> </InputWrapper>
@ -397,15 +397,15 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
)} )}
</SearchResults> </SearchResults>
<ToolBar> <ToolBar>
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={allRanges.length === 0}> <ActionIconButton onClick={prevButtonOnClick} disabled={allRanges.length === 0}>
<ChevronUp size={18} /> <ChevronUp size={18} />
</ToolbarButton> </ActionIconButton>
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={allRanges.length === 0}> <ActionIconButton onClick={nextButtonOnClick} disabled={allRanges.length === 0}>
<ChevronDown size={18} /> <ChevronDown size={18} />
</ToolbarButton> </ActionIconButton>
<ToolbarButton type="text" onClick={closeButtonOnClick}> <ActionIconButton onClick={closeButtonOnClick}>
<X size={18} /> <X size={18} />
</ToolbarButton> </ActionIconButton>
</ToolBar> </ToolBar>
</SearchBarContainer> </SearchBarContainer>
</NarrowLayout> </NarrowLayout>

View File

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

View File

@ -36,7 +36,7 @@ export const OGCard = ({ link, show }: Props) => {
const GeneratedGraph = useCallback(() => { const GeneratedGraph = useCallback(() => {
return ( return (
<div className="flex h-36 items-center justify-center bg-accent p-4"> <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> </div>
) )
}, [hostname, metadata]) }, [hostname, metadata])

View File

@ -1,5 +1,18 @@
import React from 'react' 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 QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
export type QuickPanelTriggerInfo = { export type QuickPanelTriggerInfo = {
type: 'input' | 'button' type: 'input' | 'button'

View File

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

View File

@ -43,7 +43,7 @@ const CustomTag: FC<CustomTagProps> = ({
...(disabled && { cursor: 'not-allowed' }), ...(disabled && { cursor: 'not-allowed' }),
...style ...style
}}> }}>
{icon && icon} {children} {icon} {children}
{closable && ( {closable && (
<CloseIcon <CloseIcon
$size={size} $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 }) => { 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>( const [actualTheme, setActualTheme] = useState<ThemeMode>(
window.matchMedia('(prefers-color-scheme: dark)').matches ? ThemeMode.dark : ThemeMode.light 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.classList.add('light')
} }
document.body.setAttribute('navbar-position', navbarPosition) document.body.setAttribute('navbar-position', navbarPosition)
document.documentElement.lang = language
// if theme is old auto, then set theme to system // if theme is old auto, then set theme to system
// we can delete this after next big release // 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) document.body.setAttribute('theme-mode', actualTheme)
setActualTheme(actualTheme) setActualTheme(actualTheme)
}) })
}, [actualTheme, initUserTheme, navbarPosition, setSettedTheme, settedTheme]) }, [actualTheme, initUserTheme, language, navbarPosition, setSettedTheme, settedTheme])
useEffect(() => { useEffect(() => {
tailwindThemeChange(actualTheme) tailwindThemeChange(actualTheme)

View File

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

View File

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

View File

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

View File

@ -381,8 +381,9 @@
"translate": "Translate to {{target_language}}", "translate": "Translate to {{target_language}}",
"translating": "Translating...", "translating": "Translating...",
"upload": { "upload": {
"attachment": "Upload attachment",
"document": "Upload document file (model does not support images)", "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..." "upload_from_local": "Upload local file..."
}, },
"url_context": "URL Context", "url_context": "URL Context",
@ -1098,10 +1099,6 @@
"error": { "error": {
"failed": "Migration failed" "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_dimensions": "Source Dimensions",
"source_model": "Source Model", "source_model": "Source Model",
"target_dimensions": "Target Dimensions", "target_dimensions": "Target Dimensions",
@ -1120,20 +1117,6 @@
"quota": "{{name}} Left Quota: {{quota}}", "quota": "{{name}} Left Quota: {{quota}}",
"quota_infinity": "{{name}} Quota: Unlimited", "quota_infinity": "{{name}} Quota: Unlimited",
"rename": "Rename", "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": "Search knowledge base",
"search_placeholder": "Enter text to search", "search_placeholder": "Enter text to search",
"settings": { "settings": {

View File

@ -381,8 +381,9 @@
"translate": "翻译成 {{target_language}}", "translate": "翻译成 {{target_language}}",
"translating": "翻译中...", "translating": "翻译中...",
"upload": { "upload": {
"attachment": "上传附件",
"document": "上传文档(模型不支持图片)", "document": "上传文档(模型不支持图片)",
"label": "上传图片或文档", "image_or_document": "上传图片或文档",
"upload_from_local": "上传本地文件..." "upload_from_local": "上传本地文件..."
}, },
"url_context": "网页上下文", "url_context": "网页上下文",
@ -1098,10 +1099,6 @@
"error": { "error": {
"failed": "迁移失败" "failed": "迁移失败"
}, },
"migrate_to_langchain": {
"content": "知识库迁移不会删除旧知识库,而是创建一个副本之后重新处理所有知识库条目,可能消耗大量 tokens请谨慎操作。",
"info": "知识库架构已更新,点击迁移到新架构"
},
"source_dimensions": "源维度", "source_dimensions": "源维度",
"source_model": "源模型", "source_model": "源模型",
"target_dimensions": "目标维度", "target_dimensions": "目标维度",
@ -1120,20 +1117,6 @@
"quota": "{{name}} 剩余额度:{{quota}}", "quota": "{{name}} 剩余额度:{{quota}}",
"quota_infinity": "{{name}} 剩余额度:无限制", "quota_infinity": "{{name}} 剩余额度:无限制",
"rename": "重命名", "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": "搜索知识库",
"search_placeholder": "输入查询内容", "search_placeholder": "输入查询内容",
"settings": { "settings": {

View File

@ -381,8 +381,9 @@
"translate": "翻譯成 {{target_language}}", "translate": "翻譯成 {{target_language}}",
"translating": "翻譯中...", "translating": "翻譯中...",
"upload": { "upload": {
"attachment": "上傳附件",
"document": "上傳文件(模型不支援圖片)", "document": "上傳文件(模型不支援圖片)",
"label": "上傳圖片或文件", "image_or_document": "上傳圖片或文件",
"upload_from_local": "上傳本地文件..." "upload_from_local": "上傳本地文件..."
}, },
"url_context": "網頁上下文", "url_context": "網頁上下文",
@ -1098,10 +1099,6 @@
"error": { "error": {
"failed": "遷移失敗" "failed": "遷移失敗"
}, },
"migrate_to_langchain": {
"content": "知識庫遷移不會刪除舊知識庫,而是建立一個副本後重新處理所有知識庫條目,可能消耗大量 tokens請謹慎操作。",
"info": "知識庫架構已更新,點擊遷移到新架構"
},
"source_dimensions": "源維度", "source_dimensions": "源維度",
"source_model": "源模型", "source_model": "源模型",
"target_dimensions": "目標維度", "target_dimensions": "目標維度",
@ -1120,20 +1117,6 @@
"quota": "{{name}} 剩餘配額:{{quota}}", "quota": "{{name}} 剩餘配額:{{quota}}",
"quota_infinity": "{{name}} 配額:無限制", "quota_infinity": "{{name}} 配額:無限制",
"rename": "重新命名", "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": "搜尋知識庫",
"search_placeholder": "輸入查詢內容", "search_placeholder": "輸入查詢內容",
"settings": { "settings": {

View File

@ -1098,10 +1098,6 @@
"error": { "error": {
"failed": "Αποτυχία μεταφοράς" "failed": "Αποτυχία μεταφοράς"
}, },
"migrate_to_langchain": {
"content": "Η μετανάστευση της βάσης γνώσεων δεν διαγράφει την παλιά βάση γνώσεων, αλλά δημιουργεί ένα αντίγραφο και στη συνέχεια επεξεργάζεται ξανά όλες τις εγγραφές της βάσης γνώσεων, κάτι που μπορεί να καταναλώσει μεγάλο αριθμό tokens, οπότε ενεργήστε με προσοχή.",
"info": "Η δομή της βάσης γνώσεων έχει ενημερωθεί, κάντε κλικ για μετεγκατάσταση στη νέα δομή"
},
"source_dimensions": "Πηγαίες διαστάσεις", "source_dimensions": "Πηγαίες διαστάσεις",
"source_model": "Πηγαίο μοντέλο", "source_model": "Πηγαίο μοντέλο",
"target_dimensions": "Προορισμένες διαστάσεις", "target_dimensions": "Προορισμένες διαστάσεις",
@ -1120,20 +1116,6 @@
"quota": "Διαθέσιμο όριο για {{name}}: {{quota}}", "quota": "Διαθέσιμο όριο για {{name}}: {{quota}}",
"quota_infinity": "Διαθέσιμο όριο για {{name}}: Απεριόριστο", "quota_infinity": "Διαθέσιμο όριο για {{name}}: Απεριόριστο",
"rename": "Μετονομασία", "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": "Αναζήτηση βάσης γνώσεων",
"search_placeholder": "Εισάγετε την αναζήτηση", "search_placeholder": "Εισάγετε την αναζήτηση",
"settings": { "settings": {

View File

@ -1098,10 +1098,6 @@
"error": { "error": {
"failed": "Error en la migración" "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_dimensions": "Dimensiones de origen",
"source_model": "Modelo de origen", "source_model": "Modelo de origen",
"target_dimensions": "Dimensiones de destino", "target_dimensions": "Dimensiones de destino",
@ -1120,20 +1116,6 @@
"quota": "Cupo restante de {{name}}: {{quota}}", "quota": "Cupo restante de {{name}}: {{quota}}",
"quota_infinity": "Cupo restante de {{name}}: ilimitado", "quota_infinity": "Cupo restante de {{name}}: ilimitado",
"rename": "Renombrar", "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": "Buscar en la base de conocimientos",
"search_placeholder": "Ingrese el contenido de la consulta", "search_placeholder": "Ingrese el contenido de la consulta",
"settings": { "settings": {

View File

@ -1098,10 +1098,6 @@
"error": { "error": {
"failed": "Erreur lors de la migration" "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_dimensions": "Dimensions source",
"source_model": "Modèle source", "source_model": "Modèle source",
"target_dimensions": "Dimensions cible", "target_dimensions": "Dimensions cible",
@ -1120,20 +1116,6 @@
"quota": "Quota restant pour {{name}} : {{quota}}", "quota": "Quota restant pour {{name}} : {{quota}}",
"quota_infinity": "Quota restant pour {{name}} : illimité", "quota_infinity": "Quota restant pour {{name}} : illimité",
"rename": "Renommer", "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": "Rechercher dans la base de connaissances",
"search_placeholder": "Entrez votre requête", "search_placeholder": "Entrez votre requête",
"settings": { "settings": {

View File

@ -1098,10 +1098,6 @@
"error": { "error": {
"failed": "移行が失敗しました" "failed": "移行が失敗しました"
}, },
"migrate_to_langchain": {
"content": "ナレッジベースの移行は旧ナレッジベースを削除せず、すべてのエントリーを再処理したコピーを作成します。大量のトークンを消費する可能性があるため、操作には十分注意してください。",
"info": "ナレッジベースのアーキテクチャが更新されました、新しいアーキテクチャに移行するにはクリックしてください"
},
"source_dimensions": "ソース次元", "source_dimensions": "ソース次元",
"source_model": "ソースモデル", "source_model": "ソースモデル",
"target_dimensions": "ターゲット次元", "target_dimensions": "ターゲット次元",
@ -1120,20 +1116,6 @@
"quota": "{{name}} 残りクォータ: {{quota}}", "quota": "{{name}} 残りクォータ: {{quota}}",
"quota_infinity": "{{name}} クォータ: 無制限", "quota_infinity": "{{name}} クォータ: 無制限",
"rename": "名前を変更", "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": "ナレッジベースを検索",
"search_placeholder": "検索するテキストを入力", "search_placeholder": "検索するテキストを入力",
"settings": { "settings": {

View File

@ -1098,10 +1098,6 @@
"error": { "error": {
"failed": "Falha na migração" "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_dimensions": "Dimensões de origem",
"source_model": "Modelo de origem", "source_model": "Modelo de origem",
"target_dimensions": "Dimensões de destino", "target_dimensions": "Dimensões de destino",
@ -1120,20 +1116,6 @@
"quota": "Cota restante de {{name}}: {{quota}}", "quota": "Cota restante de {{name}}: {{quota}}",
"quota_infinity": "Cota restante de {{name}}: ilimitada", "quota_infinity": "Cota restante de {{name}}: ilimitada",
"rename": "Renomear", "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": "Pesquisar repositório de conhecimento",
"search_placeholder": "Digite o conteúdo da consulta", "search_placeholder": "Digite o conteúdo da consulta",
"settings": { "settings": {

View File

@ -1098,10 +1098,6 @@
"error": { "error": {
"failed": "Миграция завершена с ошибками" "failed": "Миграция завершена с ошибками"
}, },
"migrate_to_langchain": {
"content": "Миграция базы знаний не удаляет старую базу, а создает ее копию с последующей повторной обработкой всех записей, что может потребовать значительного количества токенов. Пожалуйста, действуйте осторожно.",
"info": "Архитектура базы знаний обновлена, нажмите, чтобы перейти на новую архитектуру"
},
"source_dimensions": "Исходная размерность", "source_dimensions": "Исходная размерность",
"source_model": "Исходная модель", "source_model": "Исходная модель",
"target_dimensions": "Целевая размерность", "target_dimensions": "Целевая размерность",
@ -1120,20 +1116,6 @@
"quota": "{{name}} Остаток квоты: {{quota}}", "quota": "{{name}} Остаток квоты: {{quota}}",
"quota_infinity": "{{name}} Квота: Не ограничена", "quota_infinity": "{{name}} Квота: Не ограничена",
"rename": "Переименовать", "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": "Поиск в базе знаний",
"search_placeholder": "Введите текст для поиска", "search_placeholder": "Введите текст для поиска",
"settings": { "settings": {

View File

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

View File

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

View File

@ -1,12 +1,17 @@
import { FileType } from '@renderer/types' import { ActionIconButton } from '@renderer/components/Buttons'
import { filterSupportedFiles } from '@renderer/utils/file' 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 { Tooltip } from 'antd'
import { Paperclip } from 'lucide-react' import dayjs from 'dayjs'
import { FC, useCallback, useImperativeHandle, useState } from 'react' import { FileSearch, FileText, Paperclip, Upload } from 'lucide-react'
import { Dispatch, FC, SetStateAction, useCallback, useImperativeHandle, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export interface AttachmentButtonRef { export interface AttachmentButtonRef {
openQuickPanel: () => void openQuickPanel: () => void
openFileSelectDialog: () => void
} }
interface Props { interface Props {
@ -14,24 +19,17 @@ interface Props {
couldAddImageFile: boolean couldAddImageFile: boolean
extensions: string[] extensions: string[]
files: FileType[] files: FileType[]
setFiles: (files: FileType[]) => void setFiles: Dispatch<SetStateAction<FileType[]>>
ToolbarButton: any
disabled?: boolean disabled?: boolean
} }
const AttachmentButton: FC<Props> = ({ const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files, setFiles, disabled }) => {
ref,
couldAddImageFile,
extensions,
files,
setFiles,
ToolbarButton,
disabled
}) => {
const { t } = useTranslation() const { t } = useTranslation()
const quickPanel = useQuickPanel()
const { bases: knowledgeBases } = useKnowledgeBases()
const [selecting, setSelecting] = useState<boolean>(false) const [selecting, setSelecting] = useState<boolean>(false)
const onSelectFile = useCallback(async () => { const openFileSelectDialog = useCallback(async () => {
if (selecting) { if (selecting) {
return return
} }
@ -70,23 +68,88 @@ const AttachmentButton: FC<Props> = ({
} }
}, [extensions, files, selecting, setFiles, t]) }, [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(() => { const openQuickPanel = useCallback(() => {
onSelectFile() quickPanel.open({
}, [onSelectFile]) title: t('chat.input.upload.attachment'),
list: items,
symbol: QuickPanelReservedSymbol.File
})
}, [items, quickPanel, t])
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
openQuickPanel openQuickPanel,
openFileSelectDialog
})) }))
return ( return (
<Tooltip <Tooltip
placement="top" 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} mouseLeaveDelay={0}
arrow> arrow>
<ToolbarButton type="text" onClick={onSelectFile} disabled={disabled}> <ActionIconButton onClick={openFileSelectDialog} active={files.length > 0} disabled={disabled}>
<Paperclip size={18} style={{ color: files.length ? 'var(--color-primary)' : 'var(--color-icon)' }} /> <Paperclip size={18} />
</ToolbarButton> </ActionIconButton>
</Tooltip> </Tooltip>
) )
} }

View File

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

View File

@ -1,25 +1,23 @@
import { HolderOutlined } from '@ant-design/icons' import { HolderOutlined } from '@ant-design/icons'
import { loggerService } from '@logger' 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 TranslateButton from '@renderer/components/TranslateButton'
import { import {
isAutoEnableImageGenerationModel, isAutoEnableImageGenerationModel,
isGenerateImageModel, isGenerateImageModel,
isGenerateImageModels, isGenerateImageModels,
isMandatoryWebSearchModel, isMandatoryWebSearchModel,
isSupportedReasoningEffortModel,
isSupportedThinkingTokenModel,
isVisionModel, isVisionModel,
isVisionModels, isVisionModels,
isWebSearchModel isWebSearchModel
} from '@renderer/config/models' } from '@renderer/config/models'
import db from '@renderer/databases' import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime' import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings' 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 { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
import { useTimer } from '@renderer/hooks/useTimer' import { useTimer } from '@renderer/hooks/useTimer'
import useTranslate from '@renderer/hooks/useTranslate' 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 { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService' import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService'
import { getModelUniqId } from '@renderer/services/ModelService'
import PasteService from '@renderer/services/PasteService' import PasteService from '@renderer/services/PasteService'
import { spanManagerService } from '@renderer/services/SpanManagerService' import { spanManagerService } from '@renderer/services/SpanManagerService'
import { estimateTextTokens as estimateTxtTokens, estimateUserPromptUsage } from '@renderer/services/TokenService' 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 { useAppDispatch, useAppSelector } from '@renderer/store'
import { setSearching } from '@renderer/store/runtime' import { setSearching } from '@renderer/store/runtime'
import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk' 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 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 { formatQuotedText } from '@renderer/utils/formats'
import { import {
getFilesFromDropEvent, getFilesFromDropEvent,
@ -46,14 +43,12 @@ import {
getTextFromDropEvent, getTextFromDropEvent,
isSendMessageKeyPressed isSendMessageKeyPressed
} from '@renderer/utils/input' } from '@renderer/utils/input'
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
import { documentExts, imageExts, textExts } from '@shared/config/constant' import { documentExts, imageExts, textExts } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
import { Button, Tooltip } from 'antd' import { Tooltip } from 'antd'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import dayjs from 'dayjs'
import { debounce, isEmpty } from 'lodash' 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 React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -114,7 +109,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const [textareaHeight, setTextareaHeight] = useState<number>() const [textareaHeight, setTextareaHeight] = useState<number>()
const startDragY = useRef<number>(0) const startDragY = useRef<number>(0)
const startHeight = useRef<number>(0) const startHeight = useRef<number>(0)
const { bases: knowledgeBases } = useKnowledgeBases()
const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode) const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode)
const isVisionAssistant = useMemo(() => isVisionModel(model), [model]) const isVisionAssistant = useMemo(() => isVisionModel(model), [model])
const isGenerateImageAssistant = useMemo(() => isGenerateImageModel(model), [model]) const isGenerateImageAssistant = useMemo(() => isGenerateImageModel(model), [model])
@ -134,11 +128,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
[mentionedModels, isGenerateImageAssistant] [mentionedModels, isGenerateImageAssistant]
) )
// 仅允许在不含图片文件时mention非视觉模型
const couldMentionNotVisionModel = useMemo(() => {
return !files.some((file) => file.type === FileTypes.IMAGE)
}, [files])
// 允许在支持视觉或生成图片时添加图片文件 // 允许在支持视觉或生成图片时添加图片文件
const couldAddImageFile = useMemo(() => { const couldAddImageFile = useMemo(() => {
return isVisionSupported || isGenerateImageSupported return isVisionSupported || isGenerateImageSupported
@ -185,8 +174,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const inputTokenCount = showInputEstimatedTokens ? tokenCount : 0 const inputTokenCount = showInputEstimatedTokens ? tokenCount : 0
const newTopicShortcut = useShortcutDisplay('new_topic')
const cleanTopicShortcut = useShortcutDisplay('clear_topic')
const inputEmpty = isEmpty(text.trim()) && files.length === 0 const inputEmpty = isEmpty(text.trim()) && files.length === 0
_text = text _text = text
@ -279,72 +266,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
} }
}, [isTranslating, text, getLanguageByLangcode, targetLanguage, setTimeoutTimer, resizeTextArea]) }, [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>) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
// 按下Tab键自动选中${xxx} // 按下Tab键自动选中${xxx}
if (event.key === 'Tab' && inputFocus) { if (event.key === 'Tab' && inputFocus) {
@ -406,37 +327,40 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
//other keys should be ignored //other keys should be ignored
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
if (isEnterPressed) { if (isEnterPressed) {
if (quickPanel.isVisible) return event.preventDefault() // 1) 优先判断是否为“发送”(当前仅支持纯 Enter 发送;其余 Enter 组合键均换行)
if (isSendMessageKeyPressed(event, sendMessageShortcut)) { if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
sendMessage() sendMessage()
return event.preventDefault() 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 // 2) 不再基于 quickPanel.isVisible 主动拦截。
if (textArea) { // 纯 Enter 的处理权交由 QuickPanel 的全局捕获(其只在纯 Enter 时拦截),
const start = textArea.selectionStart // 其它带修饰键的 Enter 则由输入框处理为换行。
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 if (event.shiftKey) {
setText(newText) return
}
// set cursor position in the next render cycle event.preventDefault()
setTimeoutTimer( const textArea = textareaRef.current?.resizableTextArea?.textArea
'handleKeyDown', if (textArea) {
() => { const start = textArea.selectionStart
textArea.selectionStart = textArea.selectionEnd = start + 1 const end = textArea.selectionEnd
onInput() // trigger resizeTextArea const text = textArea.value
}, const newText = text.substring(0, start) + '\n' + text.substring(end)
0
) // 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] const lastSymbol = newText[cursorPosition - 1]
// 触发符号为 '/':若当前未打开或符号不同,则切换/打开 // 触发符号为 '/':若当前未打开或符号不同,则切换/打开
if (enableQuickPanelTriggers && lastSymbol === '/') { if (enableQuickPanelTriggers && lastSymbol === QuickPanelReservedSymbol.Root) {
if (quickPanel.isVisible && quickPanel.symbol !== '/') { if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.Root) {
quickPanel.close('switch-symbol') quickPanel.close('switch-symbol')
} }
if (!quickPanel.isVisible || quickPanel.symbol !== '/') { if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.Root) {
const quickPanelMenu = const quickPanelMenu =
inputbarToolsRef.current?.getQuickPanelMenu({ inputbarToolsRef.current?.getQuickPanelMenu({
t,
files,
couldAddImageFile,
text: newText, text: newText,
openSelectFileMenu,
translate translate
}) || [] }) || []
quickPanel.open({ quickPanel.open({
title: t('settings.quickPanel.title'), title: t('settings.quickPanel.title'),
list: quickPanelMenu, list: quickPanelMenu,
symbol: '/' symbol: QuickPanelReservedSymbol.Root
}) })
} }
} }
// 触发符号为 '@':若当前未打开或符号不同,则切换/打开 // 触发符号为 '@':若当前未打开或符号不同,则切换/打开
if (enableQuickPanelTriggers && lastSymbol === '@') { if (enableQuickPanelTriggers && lastSymbol === QuickPanelReservedSymbol.MentionModels) {
if (quickPanel.isVisible && quickPanel.symbol !== '@') { if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) {
quickPanel.close('switch-symbol') quickPanel.close('switch-symbol')
} }
if (!quickPanel.isVisible || quickPanel.symbol !== '@') { if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) {
inputbarToolsRef.current?.openMentionModelsPanel({ inputbarToolsRef.current?.openMentionModelsPanel({
type: 'input', type: 'input',
position: cursorPosition - 1, 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( const onPaste = useCallback(
@ -762,11 +682,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
setSelectedKnowledgeBases(showKnowledgeIcon ? (assistant.knowledge_bases ?? []) : []) setSelectedKnowledgeBases(showKnowledgeIcon ? (assistant.knowledge_bases ?? []) : [])
}, [assistant.id, assistant.knowledge_bases, showKnowledgeIcon]) }, [assistant.id, assistant.knowledge_bases, showKnowledgeIcon])
const handleKnowledgeBaseSelect = (bases?: KnowledgeBase[]) => {
updateAssistant({ ...assistant, knowledge_bases: bases })
setSelectedKnowledgeBases(bases ?? [])
}
const handleRemoveModel = (model: Model) => { const handleRemoveModel = (model: Model) => {
setMentionedModels(mentionedModels.filter((m) => m.id !== model.id)) setMentionedModels(mentionedModels.filter((m) => m.id !== model.id))
} }
@ -780,10 +695,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
setSelectedKnowledgeBases(newKnowledgeBases ?? []) setSelectedKnowledgeBases(newKnowledgeBases ?? [])
} }
const onEnableGenerateImage = () => {
updateAssistant({ ...assistant, enableGenerateImage: !assistant.enableGenerateImage })
}
useEffect(() => { useEffect(() => {
if (!isWebSearchModel(model) && assistant.enableWebSearch) { if (!isWebSearchModel(model) && assistant.enableWebSearch) {
updateAssistant({ ...assistant, enableWebSearch: false }) updateAssistant({ ...assistant, enableWebSearch: false })
@ -803,24 +714,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
} }
}, [assistant, model, updateAssistant]) }, [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 onToggleExpanded = () => {
const currentlyExpanded = expanded || !!textareaHeight const currentlyExpanded = expanded || !!textareaHeight
const shouldExpand = !currentlyExpanded const shouldExpand = !currentlyExpanded
@ -845,8 +738,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
} }
const isExpanded = expanded || !!textareaHeight const isExpanded = expanded || !!textareaHeight
const showThinkingButton = isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)
const showMcpTools = isSupportedToolUse(assistant) || isPromptToolUse(assistant)
if (isMultiSelectMode) { if (isMultiSelectMode) {
return null return null
@ -918,47 +809,38 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
<Toolbar> <Toolbar>
<InputbarTools <InputbarTools
ref={inputbarToolsRef} ref={inputbarToolsRef}
assistant={assistant} assistantId={assistant.id}
model={model} model={model}
files={files} files={files}
extensions={supportedExts} extensions={supportedExts}
setFiles={setFiles} setFiles={setFiles}
showThinkingButton={showThinkingButton}
showKnowledgeIcon={showKnowledgeIcon && showMcpTools}
showMcpTools={showMcpTools}
selectedKnowledgeBases={selectedKnowledgeBases}
handleKnowledgeBaseSelect={handleKnowledgeBaseSelect}
setText={setText} setText={setText}
resizeTextArea={resizeTextArea} resizeTextArea={resizeTextArea}
mentionModels={mentionedModels} selectedKnowledgeBases={selectedKnowledgeBases}
onMentionModel={onMentionModel} setSelectedKnowledgeBases={setSelectedKnowledgeBases}
onClearMentionModels={onClearMentionModels} mentionedModels={mentionedModels}
couldMentionNotVisionModel={couldMentionNotVisionModel} setMentionedModels={setMentionedModels}
couldAddImageFile={couldAddImageFile} couldAddImageFile={couldAddImageFile}
onEnableGenerateImage={onEnableGenerateImage}
isExpanded={isExpanded} isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded} onToggleExpanded={onToggleExpanded}
addNewTopic={addNewTopic} addNewTopic={addNewTopic}
clearTopic={clearTopic} clearTopic={clearTopic}
onNewContext={onNewContext} onNewContext={onNewContext}
newTopicShortcut={newTopicShortcut}
cleanTopicShortcut={cleanTopicShortcut}
/> />
<ToolbarMenu> <ToolbarMenu>
<TokenCount <TokenCount
estimateTokenCount={estimateTokenCount} estimateTokenCount={estimateTokenCount}
inputTokenCount={inputTokenCount} inputTokenCount={inputTokenCount}
contextCount={contextCount} contextCount={contextCount}
ToolbarButton={ToolbarButton}
onClick={onNewContext} onClick={onNewContext}
/> />
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} /> <TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
<SendMessageButton sendMessage={sendMessage} disabled={inputEmpty} /> <SendMessageButton sendMessage={sendMessage} disabled={inputEmpty} />
{loading && ( {loading && (
<Tooltip placement="top" title={t('chat.input.pause')} mouseLeaveDelay={0} arrow> <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)" /> <CirclePause size={20} color="var(--color-error)" />
</ToolbarButton> </ActionIconButton>
</Tooltip> </Tooltip>
)} )}
</ToolbarMenu> </ToolbarMenu>
@ -1073,45 +955,4 @@ const ToolbarMenu = styled.div`
gap: 6px; 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 export default Inputbar

View File

@ -1,12 +1,26 @@
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd' 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 { 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 { 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 { getProviderByModel } from '@renderer/services/AssistantService'
import { getModelUniqId } from '@renderer/services/ModelService'
import { useAppDispatch, useAppSelector } from '@renderer/store' import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools' 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 { classNames } from '@renderer/utils'
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
import { Divider, Dropdown, Tooltip } from 'antd' import { Divider, Dropdown, Tooltip } from 'antd'
import { ItemType } from 'antd/es/menu/interface' import { ItemType } from 'antd/es/menu/interface'
import { import {
@ -32,7 +46,6 @@ import styled from 'styled-components'
import AttachmentButton, { AttachmentButtonRef } from './AttachmentButton' import AttachmentButton, { AttachmentButtonRef } from './AttachmentButton'
import GenerateImageButton from './GenerateImageButton' import GenerateImageButton from './GenerateImageButton'
import { ToolbarButton } from './Inputbar'
import KnowledgeBaseButton, { KnowledgeBaseButtonRef } from './KnowledgeBaseButton' import KnowledgeBaseButton, { KnowledgeBaseButtonRef } from './KnowledgeBaseButton'
import MCPToolsButton, { MCPToolsButtonRef } from './MCPToolsButton' import MCPToolsButton, { MCPToolsButtonRef } from './MCPToolsButton'
import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButton' import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButton'
@ -42,47 +55,33 @@ import ThinkingButton, { ThinkingButtonRef } from './ThinkingButton'
import UrlContextButton, { UrlContextButtonRef } from './UrlContextbutton' import UrlContextButton, { UrlContextButtonRef } from './UrlContextbutton'
import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton' import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton'
const logger = loggerService.withContext('InputbarTools')
export interface InputbarToolsRef { export interface InputbarToolsRef {
getQuickPanelMenu: (params: { getQuickPanelMenu: (params: { text: string; translate: () => void }) => QuickPanelListItem[]
t: (key: string, options?: any) => string
files: FileType[]
couldAddImageFile: boolean
text: string
openSelectFileMenu: () => void
translate: () => void
}) => QuickPanelListItem[]
openMentionModelsPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void openMentionModelsPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void
openAttachmentQuickPanel: () => void openAttachmentQuickPanel: () => void
} }
export interface InputbarToolsProps { export interface InputbarToolsProps {
assistant: Assistant assistantId: string
model: Model model: Model
files: FileType[] files: FileType[]
setFiles: (files: FileType[]) => void setFiles: Dispatch<SetStateAction<FileType[]>>
extensions: string[] extensions: string[]
showThinkingButton: boolean
showKnowledgeIcon: boolean
showMcpTools: boolean
selectedKnowledgeBases: KnowledgeBase[]
handleKnowledgeBaseSelect: (bases?: KnowledgeBase[]) => void
setText: Dispatch<SetStateAction<string>> setText: Dispatch<SetStateAction<string>>
resizeTextArea: () => void resizeTextArea: () => void
mentionModels: Model[] selectedKnowledgeBases: KnowledgeBase[]
onMentionModel: (model: Model) => void setSelectedKnowledgeBases: Dispatch<SetStateAction<KnowledgeBase[]>>
onClearMentionModels: () => void mentionedModels: Model[]
couldMentionNotVisionModel: boolean setMentionedModels: Dispatch<SetStateAction<Model[]>>
couldAddImageFile: boolean couldAddImageFile: boolean
onEnableGenerateImage: () => void
isExpanded: boolean isExpanded: boolean
onToggleExpanded: () => void onToggleExpanded: () => void
addNewTopic: () => void addNewTopic: () => void
clearTopic: () => void clearTopic: () => void
onNewContext: () => void onNewContext: () => void
newTopicShortcut: string
cleanTopicShortcut: string
} }
interface ToolButtonConfig { interface ToolButtonConfig {
@ -100,34 +99,27 @@ const DraggablePortal = ({ children, isDragging }) => {
const InputbarTools = ({ const InputbarTools = ({
ref, ref,
assistant, assistantId,
model, model,
files, files,
setFiles, setFiles,
showThinkingButton,
showKnowledgeIcon,
showMcpTools,
selectedKnowledgeBases,
handleKnowledgeBaseSelect,
setText, setText,
resizeTextArea, resizeTextArea,
mentionModels, selectedKnowledgeBases,
onMentionModel, setSelectedKnowledgeBases,
onClearMentionModels, mentionedModels,
couldMentionNotVisionModel, setMentionedModels,
couldAddImageFile, couldAddImageFile,
onEnableGenerateImage,
isExpanded: isExpended, isExpanded: isExpended,
onToggleExpanded: onToggleExpended, onToggleExpanded: onToggleExpended,
addNewTopic, addNewTopic,
clearTopic, clearTopic,
onNewContext, onNewContext,
newTopicShortcut,
cleanTopicShortcut,
extensions extensions
}: InputbarToolsProps & { ref?: React.RefObject<InputbarToolsRef | null> }) => { }: InputbarToolsProps & { ref?: React.RefObject<InputbarToolsRef | null> }) => {
const { t } = useTranslation() const { t } = useTranslation()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { assistant, updateAssistant } = useAssistant(assistantId)
const quickPhrasesButtonRef = useRef<QuickPhrasesButtonRef>(null) const quickPhrasesButtonRef = useRef<QuickPhrasesButtonRef>(null)
const mentionModelsButtonRef = useRef<MentionModelsButtonRef>(null) const mentionModelsButtonRef = useRef<MentionModelsButtonRef>(null)
@ -143,6 +135,54 @@ const InputbarTools = ({
const [targetTool, setTargetTool] = useState<ToolButtonConfig | null>(null) 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( const toggleToolVisibility = useCallback(
(toolKey: string, isVisible: boolean | undefined) => { (toolKey: string, isVisible: boolean | undefined) => {
const newToolOrder = { const newToolOrder = {
@ -164,15 +204,8 @@ const InputbarTools = ({
[dispatch, toolOrder.hidden, toolOrder.visible] [dispatch, toolOrder.hidden, toolOrder.visible]
) )
const getQuickPanelMenuImpl = (params: { const getQuickPanelMenuImpl = (params: { text: string; translate: () => void }): QuickPanelListItem[] => {
t: (key: string, options?: any) => string const { text, translate } = params
files: FileType[]
couldAddImageFile: boolean
text: string
openSelectFileMenu: () => void
translate: () => void
}): QuickPanelListItem[] => {
const { t, files, couldAddImageFile, text, openSelectFileMenu, translate } = params
return [ 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: '', description: '',
icon: <Paperclip />, icon: <Paperclip />,
isMenu: true, isMenu: true,
action: openSelectFileMenu action: () => {
attachmentButtonRef.current?.openQuickPanel()
}
}, },
{ {
label: t('translate.title'), label: t('translate.title'),
@ -313,15 +348,15 @@ const InputbarTools = ({
title={t('chat.input.new_topic', { Command: newTopicShortcut })} title={t('chat.input.new_topic', { Command: newTopicShortcut })}
mouseLeaveDelay={0} mouseLeaveDelay={0}
arrow> arrow>
<ToolbarButton type="text" onClick={addNewTopic}> <ActionIconButton onClick={addNewTopic}>
<MessageSquareDiff size={19} /> <MessageSquareDiff size={19} />
</ToolbarButton> </ActionIconButton>
</Tooltip> </Tooltip>
) )
}, },
{ {
key: 'attachment', key: 'attachment',
label: t('chat.input.upload.label'), label: t('chat.input.upload.image_or_document'),
component: ( component: (
<AttachmentButton <AttachmentButton
ref={attachmentButtonRef} ref={attachmentButtonRef}
@ -329,28 +364,25 @@ const InputbarTools = ({
extensions={extensions} extensions={extensions}
files={files} files={files}
setFiles={setFiles} setFiles={setFiles}
ToolbarButton={ToolbarButton}
/> />
) )
}, },
{ {
key: 'thinking', key: 'thinking',
label: t('chat.input.thinking.label'), label: t('chat.input.thinking.label'),
component: ( component: <ThinkingButton ref={thinkingButtonRef} model={model} assistantId={assistant.id} />,
<ThinkingButton ref={thinkingButtonRef} model={model} assistant={assistant} ToolbarButton={ToolbarButton} />
),
condition: showThinkingButton condition: showThinkingButton
}, },
{ {
key: 'web_search', key: 'web_search',
label: t('chat.input.web_search.label'), 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) condition: !isMandatoryWebSearchModel(model)
}, },
{ {
key: 'url_context', key: 'url_context',
label: t('chat.input.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)) condition: isGeminiModel(model) && isSupportUrlContextProvider(getProviderByModel(model))
}, },
{ {
@ -361,36 +393,29 @@ const InputbarTools = ({
ref={knowledgeBaseButtonRef} ref={knowledgeBaseButtonRef}
selectedBases={selectedKnowledgeBases} selectedBases={selectedKnowledgeBases}
onSelect={handleKnowledgeBaseSelect} onSelect={handleKnowledgeBaseSelect}
ToolbarButton={ToolbarButton}
disabled={files.length > 0} disabled={files.length > 0}
/> />
), ),
condition: showKnowledgeIcon condition: showKnowledgeBaseButton
}, },
{ {
key: 'mcp_tools', key: 'mcp_tools',
label: t('settings.mcp.title'), label: t('settings.mcp.title'),
component: ( component: (
<MCPToolsButton <MCPToolsButton
assistant={assistant} assistantId={assistant.id}
ref={mcpToolsButtonRef} ref={mcpToolsButtonRef}
ToolbarButton={ToolbarButton}
setInputValue={setText} setInputValue={setText}
resizeTextArea={resizeTextArea} resizeTextArea={resizeTextArea}
/> />
), ),
condition: showMcpTools condition: showMcpServerButton
}, },
{ {
key: 'generate_image', key: 'generate_image',
label: t('chat.input.generate_image'), label: t('chat.input.generate_image'),
component: ( component: (
<GenerateImageButton <GenerateImageButton model={model} assistant={assistant} onEnableGenerateImage={onEnableGenerateImage} />
model={model}
assistant={assistant}
onEnableGenerateImage={onEnableGenerateImage}
ToolbarButton={ToolbarButton}
/>
), ),
condition: isGenerateImageModel(model) condition: isGenerateImageModel(model)
}, },
@ -400,10 +425,9 @@ const InputbarTools = ({
component: ( component: (
<MentionModelsButton <MentionModelsButton
ref={mentionModelsButtonRef} ref={mentionModelsButtonRef}
mentionedModels={mentionModels} mentionedModels={mentionedModels}
onMentionModel={onMentionModel} onMentionModel={onMentionModel}
onClearMentionModels={onClearMentionModels} onClearMentionModels={onClearMentionModels}
ToolbarButton={ToolbarButton}
couldMentionNotVisionModel={couldMentionNotVisionModel} couldMentionNotVisionModel={couldMentionNotVisionModel}
files={files} files={files}
setText={setText} setText={setText}
@ -418,8 +442,7 @@ const InputbarTools = ({
ref={quickPhrasesButtonRef} ref={quickPhrasesButtonRef}
setInputValue={setText} setInputValue={setText}
resizeTextArea={resizeTextArea} resizeTextArea={resizeTextArea}
ToolbarButton={ToolbarButton} assistantId={assistant.id}
assistantObj={assistant}
/> />
) )
}, },
@ -429,12 +452,12 @@ const InputbarTools = ({
component: ( component: (
<Tooltip <Tooltip
placement="top" placement="top"
title={t('chat.input.clear.label', { Command: cleanTopicShortcut })} title={t('chat.input.clear.label', { Command: clearTopicShortcut })}
mouseLeaveDelay={0} mouseLeaveDelay={0}
arrow> arrow>
<ToolbarButton type="text" onClick={clearTopic}> <ActionIconButton onClick={clearTopic}>
<PaintbrushVertical size={18} /> <PaintbrushVertical size={18} />
</ToolbarButton> </ActionIconButton>
</Tooltip> </Tooltip>
) )
}, },
@ -447,22 +470,22 @@ const InputbarTools = ({
title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')} title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')}
mouseLeaveDelay={0} mouseLeaveDelay={0}
arrow> arrow>
<ToolbarButton type="text" onClick={onToggleExpended}> <ActionIconButton onClick={onToggleExpended}>
{isExpended ? <Minimize size={18} /> : <Maximize size={18} />} {isExpended ? <Minimize size={18} /> : <Maximize size={18} />}
</ToolbarButton> </ActionIconButton>
</Tooltip> </Tooltip>
) )
}, },
{ {
key: 'new_context', key: 'new_context',
label: t('chat.input.new.context', { Command: '' }), label: t('chat.input.new.context', { Command: '' }),
component: <NewContextButton onNewContext={onNewContext} ToolbarButton={ToolbarButton} /> component: <NewContextButton onNewContext={onNewContext} />
} }
] ]
}, [ }, [
addNewTopic, addNewTopic,
assistant, assistant,
cleanTopicShortcut, clearTopicShortcut,
clearTopic, clearTopic,
couldAddImageFile, couldAddImageFile,
couldMentionNotVisionModel, couldMentionNotVisionModel,
@ -470,7 +493,7 @@ const InputbarTools = ({
files, files,
handleKnowledgeBaseSelect, handleKnowledgeBaseSelect,
isExpended, isExpended,
mentionModels, mentionedModels,
model, model,
newTopicShortcut, newTopicShortcut,
onClearMentionModels, onClearMentionModels,
@ -482,8 +505,8 @@ const InputbarTools = ({
selectedKnowledgeBases, selectedKnowledgeBases,
setFiles, setFiles,
setText, setText,
showKnowledgeIcon, showKnowledgeBaseButton,
showMcpTools, showMcpServerButton,
showThinkingButton, showThinkingButton,
t t
]) ])
@ -628,14 +651,14 @@ const InputbarTools = ({
placement="top" placement="top"
title={isCollapse ? t('chat.input.tools.expand') : t('chat.input.tools.collapse')} title={isCollapse ? t('chat.input.tools.expand') : t('chat.input.tools.collapse')}
arrow> arrow>
<ToolbarButton type="text" onClick={() => dispatch(setIsCollapsed(!isCollapse))}> <ActionIconButton onClick={() => dispatch(setIsCollapsed(!isCollapse))}>
<CircleChevronRight <CircleChevronRight
size={18} size={18}
style={{ style={{
transform: isCollapse ? 'scaleX(1)' : 'scaleX(-1)' transform: isCollapse ? 'scaleX(1)' : 'scaleX(-1)'
}} }}
/> />
</ToolbarButton> </ActionIconButton>
</Tooltip> </Tooltip>
)} )}
</ToolsContainer> </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 { useAppSelector } from '@renderer/store'
import { KnowledgeBase } from '@renderer/types' import { KnowledgeBase } from '@renderer/types'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
@ -16,10 +17,9 @@ interface Props {
selectedBases?: KnowledgeBase[] selectedBases?: KnowledgeBase[]
onSelect: (bases: KnowledgeBase[]) => void onSelect: (bases: KnowledgeBase[]) => void
disabled?: boolean 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 { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const quickPanel = useQuickPanel() const quickPanel = useQuickPanel()
@ -77,7 +77,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
quickPanel.open({ quickPanel.open({
title: t('chat.input.knowledge_base'), title: t('chat.input.knowledge_base'),
list: baseItems, list: baseItems,
symbol: '#', symbol: QuickPanelReservedSymbol.KnowledgeBase,
multiple: true, multiple: true,
afterAction({ item }) { afterAction({ item }) {
item.isSelected = !item.isSelected item.isSelected = !item.isSelected
@ -86,7 +86,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
}, [baseItems, quickPanel, t]) }, [baseItems, quickPanel, t])
const handleOpenQuickPanel = useCallback(() => { const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === '#') { if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
quickPanel.close() quickPanel.close()
} else { } else {
openQuickPanel() openQuickPanel()
@ -95,7 +95,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
// 监听 selectedBases 变化,动态更新已打开的 QuickPanel 列表状态 // 监听 selectedBases 变化,动态更新已打开的 QuickPanel 列表状态
useEffect(() => { useEffect(() => {
if (quickPanel.isVisible && quickPanel.symbol === '#') { if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
// 直接使用重新计算的 baseItems因为它已经包含了最新的 isSelected 状态 // 直接使用重新计算的 baseItems因为它已经包含了最新的 isSelected 状态
quickPanel.updateList(baseItems) quickPanel.updateList(baseItems)
} }
@ -107,12 +107,12 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
return ( return (
<Tooltip placement="top" title={t('chat.input.knowledge_base')} mouseLeaveDelay={0} arrow> <Tooltip placement="top" title={t('chat.input.knowledge_base')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel} disabled={disabled}> <ActionIconButton
<FileSearch onClick={handleOpenQuickPanel}
size={18} active={selectedBases && selectedBases.length > 0}
color={selectedBases && selectedBases.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)'} disabled={disabled}>
/> <FileSearch size={18} />
</ToolbarButton> </ActionIconButton>
</Tooltip> </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 { isGeminiModel } from '@renderer/config/models'
import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@renderer/config/providers' import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@renderer/config/providers'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
@ -6,7 +7,7 @@ import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { useTimer } from '@renderer/hooks/useTimer' import { useTimer } from '@renderer/hooks/useTimer'
import { getProviderByModel } from '@renderer/services/AssistantService' import { getProviderByModel } from '@renderer/services/AssistantService'
import { EventEmitter } from '@renderer/services/EventService' 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 { isToolUseModeFunction } from '@renderer/utils/assistant'
import { Form, Input, Tooltip } from 'antd' import { Form, Input, Tooltip } from 'antd'
import { CircleX, Hammer, Plus } from 'lucide-react' import { CircleX, Hammer, Plus } from 'lucide-react'
@ -21,11 +22,10 @@ export interface MCPToolsButtonRef {
} }
interface Props { interface Props {
assistant: Assistant assistantId: string
ref?: React.RefObject<MCPToolsButtonRef | null> ref?: React.RefObject<MCPToolsButtonRef | null>
setInputValue: React.Dispatch<React.SetStateAction<string>> setInputValue: React.Dispatch<React.SetStateAction<string>>
resizeTextArea: () => void resizeTextArea: () => void
ToolbarButton: any
} }
// 添加类型定义 // 添加类型定义
@ -113,14 +113,14 @@ const extractPromptContent = (response: any): string | null => {
return null return null
} }
const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, ToolbarButton, ...props }) => { const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assistantId }) => {
const { activedMcpServers } = useMCPServers() const { activedMcpServers } = useMCPServers()
const { t } = useTranslation() const { t } = useTranslation()
const quickPanel = useQuickPanel() const quickPanel = useQuickPanel()
const navigate = useNavigate() const navigate = useNavigate()
const [form] = Form.useForm() const [form] = Form.useForm()
const { updateAssistant, assistant } = useAssistant(props.assistant.id) const { assistant, updateAssistant } = useAssistant(assistantId)
const model = assistant.model const model = assistant.model
const { setTimeoutTimer } = useTimer() const { setTimeoutTimer } = useTimer()
@ -228,7 +228,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
quickPanel.open({ quickPanel.open({
title: t('settings.mcp.title'), title: t('settings.mcp.title'),
list: menuItems, list: menuItems,
symbol: 'mcp', symbol: QuickPanelReservedSymbol.Mcp,
multiple: true, multiple: true,
afterAction({ item }) { afterAction({ item }) {
item.isSelected = !item.isSelected item.isSelected = !item.isSelected
@ -318,7 +318,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
}) })
await handlePromptResponse(response) await handlePromptResponse(response)
} catch (error: Error | any) { } catch (error: any) {
if (error.message !== 'cancelled') { if (error.message !== 'cancelled') {
window.modal.error({ window.modal.error({
title: t('common.error'), title: t('common.error'),
@ -335,7 +335,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
name: prompt.name name: prompt.name
}) })
await handlePromptResponse(response) await handlePromptResponse(response)
} catch (error: Error | any) { } catch (error: any) {
window.modal.error({ window.modal.error({
title: t('common.error'), title: t('common.error'),
content: error.message || t('settings.mcp.prompts.genericError') content: error.message || t('settings.mcp.prompts.genericError')
@ -377,7 +377,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
quickPanel.open({ quickPanel.open({
title: t('settings.mcp.title'), title: t('settings.mcp.title'),
list: prompts, list: prompts,
symbol: 'mcp-prompt', symbol: QuickPanelReservedSymbol.McpPrompt,
multiple: true multiple: true
}) })
}, [promptList, quickPanel, t]) }, [promptList, quickPanel, t])
@ -416,7 +416,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
} else { } else {
processResourceContent(response as ResourceData) processResourceContent(response as ResourceData)
} }
} catch (error: Error | any) { } catch (error: any) {
window.modal.error({ window.modal.error({
title: t('common.error'), title: t('common.error'),
content: error.message || t('settings.mcp.resources.genericError') content: error.message || t('settings.mcp.resources.genericError')
@ -465,13 +465,13 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
quickPanel.open({ quickPanel.open({
title: t('settings.mcp.title'), title: t('settings.mcp.title'),
list: resourcesList, list: resourcesList,
symbol: 'mcp-resource', symbol: QuickPanelReservedSymbol.McpResource,
multiple: true multiple: true
}) })
}, [resourcesList, quickPanel, t]) }, [resourcesList, quickPanel, t])
const handleOpenQuickPanel = useCallback(() => { const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === 'mcp') { if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Mcp) {
quickPanel.close() quickPanel.close()
} else { } else {
openQuickPanel() openQuickPanel()
@ -486,12 +486,9 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
return ( return (
<Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow> <Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}> <ActionIconButton onClick={handleOpenQuickPanel} active={assistant.mcpServers && assistant.mcpServers.length > 0}>
<Hammer <Hammer size={18} />
size={18} </ActionIconButton>
color={assistant.mcpServers && assistant.mcpServers.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)'}
/>
</ToolbarButton>
</Tooltip> </Tooltip>
) )
} }

View File

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

View File

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

View File

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

View File

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

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