mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 06:30:10 +08:00
Merge remote-tracking branch 'origin/main' into feat/agents-new
This commit is contained in:
commit
c37af25525
4
.github/CODEOWNERS
vendored
Normal file
4
.github/CODEOWNERS
vendored
Normal 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
|
||||
4
.github/workflows/auto-i18n.yml
vendored
4
.github/workflows/auto-i18n.yml
vendored
@ -35,7 +35,7 @@ jobs:
|
||||
# 在临时目录安装依赖
|
||||
mkdir -p /tmp/translation-deps
|
||||
cd /tmp/translation-deps
|
||||
echo '{"dependencies": {"openai": "^5.12.2", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "prettier": "^3.5.3", "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-tailwindcss": "^0.6.14"}}' > package.json
|
||||
echo '{"dependencies": {"openai": "^5.12.2", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "@biomejs/biome": "2.2.4"}}' > package.json
|
||||
npm install --no-package-lock
|
||||
|
||||
# 设置 NODE_PATH 让项目能找到这些依赖
|
||||
@ -45,7 +45,7 @@ jobs:
|
||||
run: npx tsx scripts/auto-translate-i18n.ts
|
||||
|
||||
- name: 🔍 Format
|
||||
run: cd /tmp/translation-deps && npx prettier --write --config /home/runner/work/cherry-studio/cherry-studio/.prettierrc /home/runner/work/cherry-studio/cherry-studio/src/renderer/src/i18n/
|
||||
run: cd /tmp/translation-deps && npx biome format --config-path /home/runner/work/cherry-studio/cherry-studio/biome.jsonc --write /home/runner/work/cherry-studio/cherry-studio/src/renderer/src/i18n/
|
||||
|
||||
- name: 🔄 Commit changes
|
||||
run: |
|
||||
|
||||
7
.github/workflows/pr-ci.yml
vendored
7
.github/workflows/pr-ci.yml
vendored
@ -9,6 +9,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- v2
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -45,12 +46,12 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Format Check
|
||||
run: yarn format:check
|
||||
|
||||
- name: Lint Check
|
||||
run: yarn test:lint
|
||||
|
||||
- name: Format Check
|
||||
run: yarn format:check
|
||||
|
||||
- name: Type Check
|
||||
run: yarn typecheck
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -37,6 +37,7 @@ dist
|
||||
out
|
||||
mcp_server
|
||||
stats.html
|
||||
.eslintcache
|
||||
|
||||
# ENV
|
||||
.env
|
||||
|
||||
215
.oxlintrc.json
Normal file
215
.oxlintrc.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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/
|
||||
13
.prettierrc
13
.prettierrc
@ -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"
|
||||
}
|
||||
8
.vscode/extensions.json
vendored
8
.vscode/extensions.json
vendored
@ -1,8 +1,12 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"editorconfig.editorconfig",
|
||||
"lokalise.i18n-ally"
|
||||
"lokalise.i18n-ally",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"vitest.explorer",
|
||||
"oxc.oxc-vscode",
|
||||
"biomejs.biome",
|
||||
"typescriptteam.native-preview"
|
||||
]
|
||||
}
|
||||
|
||||
19
.vscode/settings.json
vendored
19
.vscode/settings.json
vendored
@ -1,30 +1,32 @@
|
||||
{
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[markdown]": {
|
||||
"files.trimTrailingWhitespace": false
|
||||
},
|
||||
"[scss]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.fixAll.oxc": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
@ -45,5 +47,6 @@
|
||||
"search.exclude": {
|
||||
"**/dist/**": true,
|
||||
".yarn/releases/**": true
|
||||
}
|
||||
},
|
||||
"typescript.experimental.useTsgo": true
|
||||
}
|
||||
|
||||
@ -5,3 +5,5 @@ httpTimeout: 300000
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.9.1.cjs
|
||||
npmRegistryServer: https://registry.npmjs.org
|
||||
npmPublishRegistry: https://registry.npmjs.org
|
||||
|
||||
97
biome.jsonc
Normal file
97
biome.jsonc
Normal 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 }
|
||||
}
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
## IDE Setup
|
||||
|
||||
[Cursor](https://www.cursor.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
- Editor: [Cursor](https://www.cursor.com/), etc. Any VS Code compatible editor.
|
||||
- Linter: [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)
|
||||
- Formatter: [Biome](https://marketplace.visualstudio.com/items?itemName=biomejs.biome)
|
||||
|
||||
## Project Setup
|
||||
|
||||
|
||||
@ -17,52 +17,52 @@ protocols:
|
||||
schemes:
|
||||
- cherrystudio
|
||||
files:
|
||||
- '**/*'
|
||||
- '!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}}'
|
||||
- '!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}'
|
||||
- '!**/{.editorconfig,.jekyll-metadata}'
|
||||
- '!src'
|
||||
- '!scripts'
|
||||
- '!local'
|
||||
- '!docs'
|
||||
- '!packages'
|
||||
- '!.swc'
|
||||
- '!.bin'
|
||||
- '!._*'
|
||||
- '!*.log'
|
||||
- '!stats.html'
|
||||
- '!*.md'
|
||||
- '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}'
|
||||
- '!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}'
|
||||
- '!**/{test,tests,__tests__,powered-test,coverage}/**'
|
||||
- '!**/{example,examples}/**'
|
||||
- '!**/*.{spec,test}.{js,jsx,ts,tsx}'
|
||||
- '!**/*.min.*.map'
|
||||
- '!**/*.d.ts'
|
||||
- '!**/dist/es6/**'
|
||||
- '!**/dist/demo/**'
|
||||
- '!**/amd/**'
|
||||
- '!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}'
|
||||
- '!**/{LICENSE,license,LICENSE.*,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}'
|
||||
- '!node_modules/rollup-plugin-visualizer'
|
||||
- '!node_modules/js-tiktoken'
|
||||
- '!node_modules/@tavily/core/node_modules/js-tiktoken'
|
||||
- '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}'
|
||||
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
|
||||
- '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds
|
||||
- '!node_modules/selection-hook/node_modules' # we don't need what in the node_modules dir
|
||||
- '!node_modules/selection-hook/src' # we don't need source files
|
||||
- '!node_modules/tesseract.js-core/{tesseract-core.js,tesseract-core.wasm,tesseract-core.wasm.js}' # we don't need source files
|
||||
- '!node_modules/tesseract.js-core/{tesseract-core-lstm.js,tesseract-core-lstm.wasm,tesseract-core-lstm.wasm.js}' # we don't need source files
|
||||
- '!node_modules/tesseract.js-core/{tesseract-core-simd-lstm.js,tesseract-core-simd-lstm.wasm,tesseract-core-simd-lstm.wasm.js}' # we don't need source files
|
||||
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}' # filter .node build files
|
||||
- "**/*"
|
||||
- "!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}"
|
||||
- "!electron.vite.config.{js,ts,mjs,cjs}}"
|
||||
- "!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md,biome.jsonc}"
|
||||
- "!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}"
|
||||
- "!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}"
|
||||
- "!**/{.editorconfig,.jekyll-metadata}"
|
||||
- "!src"
|
||||
- "!scripts"
|
||||
- "!local"
|
||||
- "!docs"
|
||||
- "!packages"
|
||||
- "!.swc"
|
||||
- "!.bin"
|
||||
- "!._*"
|
||||
- "!*.log"
|
||||
- "!stats.html"
|
||||
- "!*.md"
|
||||
- "!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}"
|
||||
- "!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}"
|
||||
- "!**/{test,tests,__tests__,powered-test,coverage}/**"
|
||||
- "!**/{example,examples}/**"
|
||||
- "!**/*.{spec,test}.{js,jsx,ts,tsx}"
|
||||
- "!**/*.min.*.map"
|
||||
- "!**/*.d.ts"
|
||||
- "!**/dist/es6/**"
|
||||
- "!**/dist/demo/**"
|
||||
- "!**/amd/**"
|
||||
- "!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}"
|
||||
- "!**/{LICENSE,license,LICENSE.*,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}"
|
||||
- "!node_modules/rollup-plugin-visualizer"
|
||||
- "!node_modules/js-tiktoken"
|
||||
- "!node_modules/@tavily/core/node_modules/js-tiktoken"
|
||||
- "!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}"
|
||||
- "!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}"
|
||||
- "!node_modules/selection-hook/prebuilds/**/*" # we rebuild .node, don't use prebuilds
|
||||
- "!node_modules/selection-hook/node_modules" # we don't need what in the node_modules dir
|
||||
- "!node_modules/selection-hook/src" # we don't need source files
|
||||
- "!node_modules/tesseract.js-core/{tesseract-core.js,tesseract-core.wasm,tesseract-core.wasm.js}" # we don't need source files
|
||||
- "!node_modules/tesseract.js-core/{tesseract-core-lstm.js,tesseract-core-lstm.wasm,tesseract-core-lstm.wasm.js}" # we don't need source files
|
||||
- "!node_modules/tesseract.js-core/{tesseract-core-simd-lstm.js,tesseract-core-simd-lstm.wasm,tesseract-core-simd-lstm.wasm.js}" # we don't need source files
|
||||
- "!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}" # filter .node build files
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- '**/*.{metal,exp,lib}'
|
||||
- 'node_modules/@img/sharp-libvips-*/**'
|
||||
- "**/*.{metal,exp,lib}"
|
||||
- "node_modules/@img/sharp-libvips-*/**"
|
||||
win:
|
||||
executableName: Cherry Studio
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
@ -88,7 +88,7 @@ mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
notarize: false
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
minimumSystemVersion: '20.1.0' # 最低支持 macOS 11.0
|
||||
minimumSystemVersion: "20.1.0" # 最低支持 macOS 11.0
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
@ -113,7 +113,7 @@ linux:
|
||||
rpm:
|
||||
# Workaround for electron build issue on rpm package:
|
||||
# https://github.com/electron/forge/issues/3594
|
||||
fpm: ['--rpm-rpmbuild-define=_build_id_links none']
|
||||
fpm: ["--rpm-rpmbuild-define=_build_id_links none"]
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://releases.cherry-ai.com
|
||||
|
||||
@ -4,7 +4,9 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import { resolve } from 'path'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
|
||||
import pkg from './package.json' assert { type: 'json' }
|
||||
// assert not supported by biome
|
||||
// import pkg from './package.json' assert { type: 'json' }
|
||||
import pkg from './package.json'
|
||||
|
||||
const visualizerPlugin = (type: 'renderer' | 'main') => {
|
||||
return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : []
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import electronConfigPrettier from '@electron-toolkit/eslint-config-prettier'
|
||||
import tseslint from '@electron-toolkit/eslint-config-ts'
|
||||
import eslint from '@eslint/js'
|
||||
import eslintReact from '@eslint-react/eslint-plugin'
|
||||
import { defineConfig } from 'eslint/config'
|
||||
import oxlint from 'eslint-plugin-oxlint'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
@ -10,7 +10,6 @@ import unusedImports from 'eslint-plugin-unused-imports'
|
||||
export default defineConfig([
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
electronConfigPrettier,
|
||||
eslintReact.configs['recommended-typescript'],
|
||||
reactHooks.configs['recommended-latest'],
|
||||
{
|
||||
@ -26,7 +25,6 @@ export default defineConfig([
|
||||
'simple-import-sort/exports': 'error',
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'@eslint-react/no-prop-types': 'error',
|
||||
'prettier/prettier': ['error']
|
||||
}
|
||||
},
|
||||
// Configuration for ensuring compatibility with the original ESLint(8.x) rules
|
||||
@ -53,7 +51,7 @@ export default defineConfig([
|
||||
{
|
||||
// LoggerService Custom Rules - only apply to src directory
|
||||
files: ['src/**/*.{ts,tsx,js,jsx}'],
|
||||
ignores: ['src/**/__tests__/**', 'src/**/__mocks__/**', 'src/**/*.test.*'],
|
||||
ignores: ['src/**/__tests__/**', 'src/**/__mocks__/**', 'src/**/*.test.*', 'src/preload/**'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
process.env.PRCI ? 'error' : 'warn',
|
||||
@ -128,5 +126,9 @@ export default defineConfig([
|
||||
'src/renderer/src/ui/**',
|
||||
'packages/**/dist'
|
||||
]
|
||||
}
|
||||
},
|
||||
// turn off oxlint supported rules.
|
||||
...oxlint.configs['flat/eslint'],
|
||||
...oxlint.configs['flat/typescript'],
|
||||
...oxlint.configs['flat/unicorn']
|
||||
])
|
||||
|
||||
47
package.json
47
package.json
@ -52,8 +52,8 @@
|
||||
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
|
||||
"analyze:main": "VISUALIZER_MAIN=true yarn build",
|
||||
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
|
||||
"check:i18n": "tsx scripts/check-i18n.ts",
|
||||
"sync:i18n": "tsx scripts/sync-i18n.ts",
|
||||
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
|
||||
@ -67,13 +67,16 @@
|
||||
"test:ui": "vitest --ui",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "yarn playwright test",
|
||||
"test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
|
||||
"test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache",
|
||||
"test:scripts": "vitest scripts",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix && yarn typecheck && yarn check:i18n",
|
||||
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn check:i18n",
|
||||
"format": "biome format --write && biome lint --write",
|
||||
"format:check": "biome format && biome lint",
|
||||
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
|
||||
"claude": "dotenv -e .env -- claude"
|
||||
"claude": "dotenv -e .env -- claude",
|
||||
"release:aicore:alpha": "yarn workspace @cherrystudio/ai-core version prerelease --immediate && yarn workspace @cherrystudio/ai-core npm publish --tag alpha --access public",
|
||||
"release:aicore:beta": "yarn workspace @cherrystudio/ai-core version prerelease --immediate && yarn workspace @cherrystudio/ai-core npm publish --tag beta --access public",
|
||||
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^1.0.113",
|
||||
@ -85,7 +88,6 @@
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"express": "^5.1.0",
|
||||
"express-validator": "^7.2.1",
|
||||
"faiss-node": "^0.5.1",
|
||||
"font-list": "^2.0.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"jsdom": "26.1.0",
|
||||
@ -103,17 +105,18 @@
|
||||
"@agentic/exa": "^7.3.3",
|
||||
"@agentic/searxng": "^7.3.3",
|
||||
"@agentic/tavily": "^7.3.3",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.0",
|
||||
"@ai-sdk/google-vertex": "^3.0.25",
|
||||
"@ai-sdk/mistral": "^2.0.0",
|
||||
"@ai-sdk/perplexity": "^2.0.8",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.21",
|
||||
"@ai-sdk/google-vertex": "^3.0.27",
|
||||
"@ai-sdk/mistral": "^2.0.14",
|
||||
"@ai-sdk/perplexity": "^2.0.9",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@anthropic-ai/sdk": "^0.41.0",
|
||||
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
|
||||
"@aws-sdk/client-bedrock": "^3.840.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.840.0",
|
||||
"@aws-sdk/client-s3": "^3.840.0",
|
||||
"@cherrystudio/ai-core": "workspace:*",
|
||||
"@biomejs/biome": "2.2.4",
|
||||
"@cherrystudio/ai-core": "workspace:^1.0.0-alpha.16",
|
||||
"@cherrystudio/embedjs": "^0.1.31",
|
||||
"@cherrystudio/embedjs-libsql": "^0.1.31",
|
||||
"@cherrystudio/embedjs-loader-csv": "^0.1.31",
|
||||
@ -131,7 +134,6 @@
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
@ -145,9 +147,6 @@
|
||||
"@heroui/react": "^2.8.3",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@langchain/community": "^0.3.50",
|
||||
"@langchain/core": "^0.3.68",
|
||||
"@langchain/ollama": "^0.2.1",
|
||||
"@langchain/openai": "^0.6.7",
|
||||
"@mistralai/mistralai": "^1.7.5",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
@ -213,6 +212,7 @@
|
||||
"@types/tinycolor2": "^1",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/word-extractor": "^1",
|
||||
"@typescript/native-preview": "latest",
|
||||
"@uiw/codemirror-extensions-langs": "^4.25.1",
|
||||
"@uiw/codemirror-themes-all": "^4.25.1",
|
||||
"@uiw/react-codemirror": "^4.25.1",
|
||||
@ -224,7 +224,7 @@
|
||||
"@viz-js/lang-dot": "^1.0.5",
|
||||
"@viz-js/viz": "^3.14.0",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"ai": "^5.0.38",
|
||||
"ai": "^5.0.44",
|
||||
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
|
||||
"archiver": "^7.0.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
@ -246,6 +246,7 @@
|
||||
"diff": "^8.0.2",
|
||||
"docx": "^9.0.2",
|
||||
"dompurify": "^3.2.6",
|
||||
"dotenv": "^17.2.2",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"electron": "37.4.0",
|
||||
@ -260,6 +261,7 @@
|
||||
"emoji-picker-element": "^1.22.1",
|
||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-oxlint": "^1.15.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
@ -295,13 +297,12 @@
|
||||
"notion-helper": "^1.3.22",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
|
||||
"oxlint": "^1.15.0",
|
||||
"oxlint-tsgolint": "^0.2.0",
|
||||
"p-queue": "^8.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"playwright": "^1.52.0",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@ -384,11 +385,11 @@
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [
|
||||
"prettier --write",
|
||||
"biome format --write",
|
||||
"eslint --fix"
|
||||
],
|
||||
"*.{json,yml,yaml,css,html}": [
|
||||
"prettier --write"
|
||||
"biome format --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cherrystudio/ai-core",
|
||||
"version": "1.0.0-alpha.14",
|
||||
"version": "1.0.0-alpha.16",
|
||||
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
@ -36,15 +36,15 @@
|
||||
"ai": "^5.0.26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.5",
|
||||
"@ai-sdk/azure": "^2.0.16",
|
||||
"@ai-sdk/deepseek": "^1.0.9",
|
||||
"@ai-sdk/google": "^2.0.13",
|
||||
"@ai-sdk/openai": "^2.0.26",
|
||||
"@ai-sdk/openai-compatible": "^1.0.9",
|
||||
"@ai-sdk/anthropic": "^2.0.17",
|
||||
"@ai-sdk/azure": "^2.0.30",
|
||||
"@ai-sdk/deepseek": "^1.0.17",
|
||||
"@ai-sdk/google": "^2.0.14",
|
||||
"@ai-sdk/openai": "^2.0.30",
|
||||
"@ai-sdk/openai-compatible": "^1.0.17",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@ai-sdk/provider-utils": "^3.0.4",
|
||||
"@ai-sdk/xai": "^2.0.9",
|
||||
"@ai-sdk/provider-utils": "^3.0.9",
|
||||
"@ai-sdk/xai": "^2.0.18",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -24,7 +24,6 @@ export const googleToolsPlugin = (config?: ToolConfig) =>
|
||||
if (!typedParams.tools) {
|
||||
typedParams.tools = {}
|
||||
}
|
||||
|
||||
// 使用类型安全的方式遍历配置
|
||||
;(Object.keys(config) as ToolConfigKey[]).forEach((key) => {
|
||||
if (config[key] && key in toolNameMap && key in google.tools) {
|
||||
|
||||
@ -1,26 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noEmitOnError": false,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
"outDir": "./dist",
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": "./src",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2020"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@ -67,13 +67,13 @@
|
||||
"dist"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.4",
|
||||
"@tiptap/core": "^3.2.0",
|
||||
"@tiptap/pm": "^3.2.0",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"prettier": "^3.5.3",
|
||||
"tsdown": "^0.13.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@ -87,7 +87,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"lint": "prettier ./src/ --write && eslint --fix ./src/"
|
||||
"lint": "biome format ./src/ --write && eslint --fix ./src/"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1"
|
||||
}
|
||||
|
||||
@ -75,17 +75,17 @@ export const languages: Record<string, LanguageData> = ${languagesObjectString};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a file using Prettier.
|
||||
* Formats a file using Biome.
|
||||
* @param filePath The path to the file to format.
|
||||
*/
|
||||
async function formatWithPrettier(filePath: string): Promise<void> {
|
||||
console.log('🎨 Formatting file with Prettier...')
|
||||
async function format(filePath: string): Promise<void> {
|
||||
console.log('🎨 Formatting file with Biome...')
|
||||
try {
|
||||
await execAsync(`yarn prettier --write ${filePath}`)
|
||||
console.log('✅ Prettier formatting complete.')
|
||||
await execAsync(`yarn biome format --write ${filePath}`)
|
||||
console.log('✅ Biome formatting complete.')
|
||||
} catch (e: any) {
|
||||
console.error('❌ Prettier formatting failed:', e.stdout || e.stderr)
|
||||
throw new Error('Prettier formatting failed.')
|
||||
console.error('❌ Biome formatting failed:', e.stdout || e.stderr)
|
||||
throw new Error('Biome formatting failed.')
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,7 +116,7 @@ async function updateLanguagesFile(): Promise<void> {
|
||||
await fs.writeFile(LANGUAGES_FILE_PATH, fileContent, 'utf-8')
|
||||
console.log(`✅ Successfully wrote to ${LANGUAGES_FILE_PATH}`)
|
||||
|
||||
await formatWithPrettier(LANGUAGES_FILE_PATH)
|
||||
await format(LANGUAGES_FILE_PATH)
|
||||
await checkTypeScript(LANGUAGES_FILE_PATH)
|
||||
|
||||
console.log('🎉 Successfully updated languages.ts file!')
|
||||
|
||||
@ -4,7 +4,7 @@ import { loggerService } from '../../services/LoggerService'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerErrorHandler')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
logger.error('API Server Error:', err)
|
||||
|
||||
|
||||
@ -11,11 +11,10 @@ import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
|
||||
import { FileMetadata, Notification, OcrProvider, Provider, Shortcut, SupportedOcrFile, ThemeMode } from '@types'
|
||||
import checkDiskSpace from 'check-disk-space'
|
||||
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
||||
import fontList from 'font-list'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import { apiServerService } from './services/ApiServerService'
|
||||
import appService from './services/AppService'
|
||||
@ -28,7 +27,7 @@ import DxtService from './services/DxtService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
import { fileStorage as fileManager } from './services/FileStorage'
|
||||
import FileService from './services/FileSystemService'
|
||||
import KnowledgeService from './services/knowledge/KnowledgeService'
|
||||
import KnowledgeService from './services/KnowledgeService'
|
||||
import mcpService from './services/MCPService'
|
||||
import MemoryService from './services/memory/MemoryService'
|
||||
import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceService'
|
||||
@ -827,7 +826,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
|
||||
|
||||
// OCR
|
||||
ipcMain.handle(IpcChannel.OCR_ocr, (_, ...args: Parameters<typeof ocrService.ocr>) => ocrService.ocr(...args))
|
||||
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
|
||||
ocrService.ocr(file, provider)
|
||||
)
|
||||
|
||||
// CherryIN
|
||||
ipcMain.handle(IpcChannel.Cherryin_GetSignature, (_, params) => generateSignature(params))
|
||||
|
||||
@ -139,9 +139,9 @@ export async function addFileLoader(
|
||||
|
||||
if (jsonParsed) {
|
||||
loaderReturn = await ragApplication.addLoader(new JsonLoader({ object: jsonObject }), forceReload)
|
||||
break
|
||||
}
|
||||
// fallthrough - JSON 解析失败时作为文本处理
|
||||
// oxlint-disable-next-line no-fallthrough 利用switch特性,刻意不break
|
||||
default:
|
||||
// 文本类型处理(默认)
|
||||
// 如果是其他文本类型且尚未读取文件,则读取文件
|
||||
|
||||
@ -11,7 +11,7 @@ export enum OdType {
|
||||
OdtLoader = 'OdtLoader',
|
||||
OdsLoader = 'OdsLoader',
|
||||
OdpLoader = 'OdpLoader',
|
||||
undefined = 'undefined'
|
||||
Undefined = 'undefined'
|
||||
}
|
||||
|
||||
export class OdLoader<OdType> extends BaseLoader<{ type: string }> {
|
||||
|
||||
@ -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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -308,6 +308,24 @@ class CodeToolsService {
|
||||
|
||||
// Build command to execute
|
||||
let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"`
|
||||
|
||||
// Add configuration parameters for OpenAI Codex
|
||||
if (cliTool === codeTools.openaiCodex && env.OPENAI_MODEL_PROVIDER && env.OPENAI_MODEL_PROVIDER != 'openai') {
|
||||
const provider = env.OPENAI_MODEL_PROVIDER
|
||||
const model = env.OPENAI_MODEL
|
||||
// delete the latest /
|
||||
const baseUrl = env.OPENAI_BASE_URL.replace(/\/$/, '')
|
||||
|
||||
const configParams = [
|
||||
`--config model_provider="${provider}"`,
|
||||
`--config model="${model}"`,
|
||||
`--config model_providers.${provider}.name="${provider}"`,
|
||||
`--config model_providers.${provider}.base_url="${baseUrl}"`,
|
||||
`--config model_providers.${provider}.env_key="OPENAI_API_KEY"`
|
||||
].join(' ')
|
||||
baseCommand = `${baseCommand} ${configParams}`
|
||||
}
|
||||
|
||||
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
|
||||
|
||||
if (isInstalled) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
/* oxlint-disable no-case-declarations */
|
||||
// ExportService
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
@ -1,3 +1,18 @@
|
||||
/**
|
||||
* Knowledge Service - Manages knowledge bases using RAG (Retrieval-Augmented Generation)
|
||||
*
|
||||
* This service handles creation, management, and querying of knowledge bases from various sources
|
||||
* including files, directories, URLs, sitemaps, and notes.
|
||||
*
|
||||
* Features:
|
||||
* - Concurrent task processing with workload management
|
||||
* - Multiple data source support
|
||||
* - Vector database integration
|
||||
*
|
||||
* For detailed documentation, see:
|
||||
* @see {@link ../../../docs/technical/KnowledgeService.md}
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
@ -9,32 +24,87 @@ import { loggerService } from '@logger'
|
||||
import Embeddings from '@main/knowledge/embedjs/embeddings/Embeddings'
|
||||
import { addFileLoader } from '@main/knowledge/embedjs/loader'
|
||||
import { NoteLoader } from '@main/knowledge/embedjs/loader/noteLoader'
|
||||
import { preprocessingService } from '@main/knowledge/preprocess/PreprocessingService'
|
||||
import PreprocessProvider from '@main/knowledge/preprocess/PreprocessProvider'
|
||||
import Reranker from '@main/knowledge/reranker/Reranker'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
import { TraceMethod } from '@mcp-trace/trace-core'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { LoaderReturn } from '@shared/config/types'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileMetadata, KnowledgeBaseParams, KnowledgeSearchResult } from '@types'
|
||||
import { FileMetadata, KnowledgeBaseParams, KnowledgeItem, KnowledgeSearchResult } from '@types'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { windowService } from '../WindowService'
|
||||
import {
|
||||
IKnowledgeFramework,
|
||||
KnowledgeBaseAddItemOptionsNonNullableAttribute,
|
||||
LoaderDoneReturn,
|
||||
LoaderTask,
|
||||
LoaderTaskItem,
|
||||
LoaderTaskItemState
|
||||
} from './IKnowledgeFramework'
|
||||
|
||||
const logger = loggerService.withContext('MainKnowledgeService')
|
||||
|
||||
export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
private storageDir: string
|
||||
private ragApplications: Map<string, RAGApplication> = new Map()
|
||||
private pendingDeleteFile: string
|
||||
private dbInstances: Map<string, LibSqlDb> = new Map()
|
||||
export interface KnowledgeBaseAddItemOptions {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload?: boolean
|
||||
userId?: string
|
||||
}
|
||||
|
||||
interface KnowledgeBaseAddItemOptionsNonNullableAttribute {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload: boolean
|
||||
userId: string
|
||||
}
|
||||
|
||||
interface EvaluateTaskWorkload {
|
||||
workload: number
|
||||
}
|
||||
|
||||
type LoaderDoneReturn = LoaderReturn | null
|
||||
|
||||
enum LoaderTaskItemState {
|
||||
PENDING,
|
||||
PROCESSING,
|
||||
DONE
|
||||
}
|
||||
|
||||
interface LoaderTaskItem {
|
||||
state: LoaderTaskItemState
|
||||
task: () => Promise<unknown>
|
||||
evaluateTaskWorkload: EvaluateTaskWorkload
|
||||
}
|
||||
|
||||
interface LoaderTask {
|
||||
loaderTasks: LoaderTaskItem[]
|
||||
loaderDoneReturn: LoaderDoneReturn
|
||||
}
|
||||
|
||||
interface LoaderTaskOfSet {
|
||||
loaderTasks: Set<LoaderTaskItem>
|
||||
loaderDoneReturn: LoaderDoneReturn
|
||||
}
|
||||
|
||||
interface QueueTaskItem {
|
||||
taskPromise: () => Promise<unknown>
|
||||
resolve: () => void
|
||||
evaluateTaskWorkload: EvaluateTaskWorkload
|
||||
}
|
||||
|
||||
const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => {
|
||||
return {
|
||||
loaderTasks: new Set(loaderTask.loaderTasks),
|
||||
loaderDoneReturn: loaderTask.loaderDoneReturn
|
||||
}
|
||||
}
|
||||
|
||||
class KnowledgeService {
|
||||
private storageDir = path.join(getDataPath(), 'KnowledgeBase')
|
||||
private pendingDeleteFile = path.join(this.storageDir, 'knowledge_pending_delete.json')
|
||||
// Byte based
|
||||
private workload = 0
|
||||
private processingItemCount = 0
|
||||
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
|
||||
private ragApplications: Map<string, RAGApplication> = new Map()
|
||||
private dbInstances: Map<string, LibSqlDb> = new Map()
|
||||
private static MAXIMUM_WORKLOAD = 80 * MB
|
||||
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
|
||||
private static ERROR_LOADER_RETURN: LoaderReturn = {
|
||||
entriesAdded: 0,
|
||||
uniqueId: '',
|
||||
@ -43,9 +113,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
status: 'failed'
|
||||
}
|
||||
|
||||
constructor(storageDir: string) {
|
||||
this.storageDir = storageDir
|
||||
this.pendingDeleteFile = path.join(this.storageDir, 'knowledge_pending_delete.json')
|
||||
constructor() {
|
||||
this.initStorageDir()
|
||||
this.cleanupOnStartup()
|
||||
}
|
||||
@ -160,28 +228,33 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
logger.info(`Startup cleanup completed: ${deletedCount}/${pendingDeleteIds.length} knowledge bases deleted`)
|
||||
}
|
||||
|
||||
private async getRagApplication(base: KnowledgeBaseParams): Promise<RAGApplication> {
|
||||
if (this.ragApplications.has(base.id)) {
|
||||
return this.ragApplications.get(base.id)!
|
||||
private getRagApplication = async ({
|
||||
id,
|
||||
embedApiClient,
|
||||
dimensions,
|
||||
documentCount
|
||||
}: KnowledgeBaseParams): Promise<RAGApplication> => {
|
||||
if (this.ragApplications.has(id)) {
|
||||
return this.ragApplications.get(id)!
|
||||
}
|
||||
|
||||
let ragApplication: RAGApplication
|
||||
const embeddings = new Embeddings({
|
||||
embedApiClient: base.embedApiClient,
|
||||
dimensions: base.dimensions
|
||||
embedApiClient,
|
||||
dimensions
|
||||
})
|
||||
try {
|
||||
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, base.id) })
|
||||
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
|
||||
// Save database instance for later closing
|
||||
this.dbInstances.set(base.id, libSqlDb)
|
||||
this.dbInstances.set(id, libSqlDb)
|
||||
|
||||
ragApplication = await new RAGApplicationBuilder()
|
||||
.setModel('NO_MODEL')
|
||||
.setEmbeddingModel(embeddings)
|
||||
.setVectorDatabase(libSqlDb)
|
||||
.setSearchResultCount(base.documentCount || 30)
|
||||
.setSearchResultCount(documentCount || 30)
|
||||
.build()
|
||||
this.ragApplications.set(base.id, ragApplication)
|
||||
this.ragApplications.set(id, ragApplication)
|
||||
} catch (e) {
|
||||
logger.error('Failed to create RAGApplication:', e as Error)
|
||||
throw new Error(`Failed to create RAGApplication: ${e}`)
|
||||
@ -189,14 +262,17 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
|
||||
return ragApplication
|
||||
}
|
||||
async initialize(base: KnowledgeBaseParams): Promise<void> {
|
||||
|
||||
public create = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
|
||||
await this.getRagApplication(base)
|
||||
}
|
||||
async reset(base: KnowledgeBaseParams): Promise<void> {
|
||||
const ragApp = await this.getRagApplication(base)
|
||||
await ragApp.reset()
|
||||
|
||||
public reset = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
|
||||
const ragApplication = await this.getRagApplication(base)
|
||||
await ragApplication.reset()
|
||||
}
|
||||
async delete(id: string): Promise<void> {
|
||||
|
||||
public async delete(_: Electron.IpcMainInvokeEvent, id: string): Promise<void> {
|
||||
logger.debug(`delete id: ${id}`)
|
||||
|
||||
await this.cleanupKnowledgeResources(id)
|
||||
@ -209,41 +285,15 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
this.pendingDeleteManager.add(id)
|
||||
}
|
||||
}
|
||||
getLoaderTask(options: KnowledgeBaseAddItemOptionsNonNullableAttribute): LoaderTask {
|
||||
const { item } = options
|
||||
const getRagApplication = () => this.getRagApplication(options.base)
|
||||
switch (item.type) {
|
||||
case 'file':
|
||||
return this.fileTask(getRagApplication, options)
|
||||
case 'directory':
|
||||
return this.directoryTask(getRagApplication, options)
|
||||
case 'url':
|
||||
return this.urlTask(getRagApplication, options)
|
||||
case 'sitemap':
|
||||
return this.sitemapTask(getRagApplication, options)
|
||||
case 'note':
|
||||
return this.noteTask(getRagApplication, options)
|
||||
default:
|
||||
return {
|
||||
loaderTasks: [],
|
||||
loaderDoneReturn: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async remove(options: { uniqueIds: string[]; base: KnowledgeBaseParams }): Promise<void> {
|
||||
const ragApp = await this.getRagApplication(options.base)
|
||||
for (const id of options.uniqueIds) {
|
||||
await ragApp.deleteLoader(id)
|
||||
}
|
||||
private maximumLoad() {
|
||||
return (
|
||||
this.processingItemCount >= KnowledgeService.MAXIMUM_PROCESSING_ITEM_COUNT ||
|
||||
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
|
||||
)
|
||||
}
|
||||
async search(options: { search: string; base: KnowledgeBaseParams }): Promise<KnowledgeSearchResult[]> {
|
||||
const ragApp = await this.getRagApplication(options.base)
|
||||
return await ragApp.search(options.search)
|
||||
}
|
||||
|
||||
private fileTask(
|
||||
getRagApplication: () => Promise<RAGApplication>,
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload, userId } = options
|
||||
@ -256,8 +306,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
task: async () => {
|
||||
try {
|
||||
// Add preprocessing logic
|
||||
const ragApplication = await getRagApplication()
|
||||
const fileToProcess: FileMetadata = await preprocessingService.preprocessFile(file, base, item, userId)
|
||||
const fileToProcess: FileMetadata = await this.preprocessing(file, base, item, userId)
|
||||
|
||||
// Use processed file for loading
|
||||
return addFileLoader(ragApplication, fileToProcess, base, forceReload)
|
||||
@ -268,7 +317,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
.catch((e) => {
|
||||
logger.error(`Error in addFileLoader for ${file.name}: ${e}`)
|
||||
const errorResult: LoaderReturn = {
|
||||
...EmbedJsFramework.ERROR_LOADER_RETURN,
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: e.message,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
@ -278,7 +327,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
} catch (e: any) {
|
||||
logger.error(`Preprocessing failed for ${file.name}: ${e}`)
|
||||
const errorResult: LoaderReturn = {
|
||||
...EmbedJsFramework.ERROR_LOADER_RETURN,
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: e.message,
|
||||
messageSource: 'preprocess'
|
||||
}
|
||||
@ -295,7 +344,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
return loaderTask
|
||||
}
|
||||
private directoryTask(
|
||||
getRagApplication: () => Promise<RAGApplication>,
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
@ -322,9 +371,8 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
for (const file of files) {
|
||||
loaderTasks.push({
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: async () => {
|
||||
const ragApplication = await getRagApplication()
|
||||
return addFileLoader(ragApplication, file, base, forceReload)
|
||||
task: () =>
|
||||
addFileLoader(ragApplication, file, base, forceReload)
|
||||
.then((result) => {
|
||||
loaderDoneReturn.entriesAdded += 1
|
||||
processedFiles += 1
|
||||
@ -335,12 +383,11 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
.catch((err) => {
|
||||
logger.error('Failed to add dir loader:', err)
|
||||
return {
|
||||
...EmbedJsFramework.ERROR_LOADER_RETURN,
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add dir loader: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
})
|
||||
},
|
||||
}),
|
||||
evaluateTaskWorkload: { workload: file.size }
|
||||
})
|
||||
}
|
||||
@ -352,7 +399,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
}
|
||||
|
||||
private urlTask(
|
||||
getRagApplication: () => Promise<RAGApplication>,
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
@ -362,8 +409,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: async () => {
|
||||
const ragApplication = await getRagApplication()
|
||||
task: () => {
|
||||
const loaderReturn = ragApplication.addLoader(
|
||||
new WebLoader({
|
||||
urlOrContent: content,
|
||||
@ -387,7 +433,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
.catch((err) => {
|
||||
logger.error('Failed to add url loader:', err)
|
||||
return {
|
||||
...EmbedJsFramework.ERROR_LOADER_RETURN,
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add url loader: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
@ -402,7 +448,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
}
|
||||
|
||||
private sitemapTask(
|
||||
getRagApplication: () => Promise<RAGApplication>,
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
@ -412,9 +458,8 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: async () => {
|
||||
const ragApplication = await getRagApplication()
|
||||
return ragApplication
|
||||
task: () =>
|
||||
ragApplication
|
||||
.addLoader(
|
||||
new SitemapLoader({ url: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }) as any,
|
||||
forceReload
|
||||
@ -432,12 +477,11 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
.catch((err) => {
|
||||
logger.error('Failed to add sitemap loader:', err)
|
||||
return {
|
||||
...EmbedJsFramework.ERROR_LOADER_RETURN,
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add sitemap loader: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
})
|
||||
},
|
||||
}),
|
||||
evaluateTaskWorkload: { workload: 20 * MB }
|
||||
}
|
||||
],
|
||||
@ -447,7 +491,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
}
|
||||
|
||||
private noteTask(
|
||||
getRagApplication: () => Promise<RAGApplication>,
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
@ -460,8 +504,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: async () => {
|
||||
const ragApplication = await getRagApplication()
|
||||
task: () => {
|
||||
const loaderReturn = ragApplication.addLoader(
|
||||
new NoteLoader({
|
||||
text: content,
|
||||
@ -484,7 +527,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
.catch((err) => {
|
||||
logger.error('Failed to add note loader:', err)
|
||||
return {
|
||||
...EmbedJsFramework.ERROR_LOADER_RETURN,
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add note loader: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
@ -497,4 +540,199 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
}
|
||||
return loaderTask
|
||||
}
|
||||
|
||||
private processingQueueHandle() {
|
||||
const getSubtasksUntilMaximumLoad = (): QueueTaskItem[] => {
|
||||
const queueTaskList: QueueTaskItem[] = []
|
||||
that: for (const [task, resolve] of this.knowledgeItemProcessingQueueMappingPromise) {
|
||||
for (const item of task.loaderTasks) {
|
||||
if (this.maximumLoad()) {
|
||||
break that
|
||||
}
|
||||
|
||||
const { state, task: taskPromise, evaluateTaskWorkload } = item
|
||||
|
||||
if (state !== LoaderTaskItemState.PENDING) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { workload } = evaluateTaskWorkload
|
||||
this.workload += workload
|
||||
this.processingItemCount += 1
|
||||
item.state = LoaderTaskItemState.PROCESSING
|
||||
queueTaskList.push({
|
||||
taskPromise: () =>
|
||||
taskPromise().then(() => {
|
||||
this.workload -= workload
|
||||
this.processingItemCount -= 1
|
||||
task.loaderTasks.delete(item)
|
||||
if (task.loaderTasks.size === 0) {
|
||||
this.knowledgeItemProcessingQueueMappingPromise.delete(task)
|
||||
resolve()
|
||||
}
|
||||
this.processingQueueHandle()
|
||||
}),
|
||||
resolve: () => {},
|
||||
evaluateTaskWorkload
|
||||
})
|
||||
}
|
||||
}
|
||||
return queueTaskList
|
||||
}
|
||||
const subTasks = getSubtasksUntilMaximumLoad()
|
||||
if (subTasks.length > 0) {
|
||||
const subTaskPromises = subTasks.map(({ taskPromise }) => taskPromise())
|
||||
Promise.all(subTaskPromises).then(() => {
|
||||
subTasks.forEach(({ resolve }) => resolve())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private appendProcessingQueue(task: LoaderTask): Promise<LoaderReturn> {
|
||||
return new Promise((resolve) => {
|
||||
this.knowledgeItemProcessingQueueMappingPromise.set(loaderTaskIntoOfSet(task), () => {
|
||||
resolve(task.loaderDoneReturn!)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
|
||||
return new Promise((resolve) => {
|
||||
const { base, item, forceReload = false, userId = '' } = options
|
||||
const optionsNonNullableAttribute = { base, item, forceReload, userId }
|
||||
this.getRagApplication(base)
|
||||
.then((ragApplication) => {
|
||||
const task = (() => {
|
||||
switch (item.type) {
|
||||
case 'file':
|
||||
return this.fileTask(ragApplication, optionsNonNullableAttribute)
|
||||
case 'directory':
|
||||
return this.directoryTask(ragApplication, optionsNonNullableAttribute)
|
||||
case 'url':
|
||||
return this.urlTask(ragApplication, optionsNonNullableAttribute)
|
||||
case 'sitemap':
|
||||
return this.sitemapTask(ragApplication, optionsNonNullableAttribute)
|
||||
case 'note':
|
||||
return this.noteTask(ragApplication, optionsNonNullableAttribute)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
if (task) {
|
||||
this.appendProcessingQueue(task).then(() => {
|
||||
resolve(task.loaderDoneReturn!)
|
||||
})
|
||||
this.processingQueueHandle()
|
||||
} else {
|
||||
resolve({
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: 'Unsupported item type',
|
||||
messageSource: 'embedding'
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Failed to add item:', err)
|
||||
resolve({
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add item: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@TraceMethod({ spanName: 'remove', tag: 'Knowledge' })
|
||||
public async remove(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }
|
||||
): Promise<void> {
|
||||
const ragApplication = await this.getRagApplication(base)
|
||||
logger.debug(`Remove Item UniqueId: ${uniqueId}`)
|
||||
for (const id of uniqueIds) {
|
||||
await ragApplication.deleteLoader(id)
|
||||
}
|
||||
}
|
||||
|
||||
@TraceMethod({ spanName: 'RagSearch', tag: 'Knowledge' })
|
||||
public async search(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ search, base }: { search: string; base: KnowledgeBaseParams }
|
||||
): Promise<KnowledgeSearchResult[]> {
|
||||
const ragApplication = await this.getRagApplication(base)
|
||||
return await ragApplication.search(search)
|
||||
}
|
||||
|
||||
@TraceMethod({ spanName: 'rerank', tag: 'Knowledge' })
|
||||
public async rerank(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ search, base, results }: { search: string; base: KnowledgeBaseParams; results: KnowledgeSearchResult[] }
|
||||
): Promise<KnowledgeSearchResult[]> {
|
||||
if (results.length === 0) {
|
||||
return results
|
||||
}
|
||||
return await new Reranker(base).rerank(search, results)
|
||||
}
|
||||
|
||||
public getStorageDir = (): string => {
|
||||
return this.storageDir
|
||||
}
|
||||
|
||||
private preprocessing = async (
|
||||
file: FileMetadata,
|
||||
base: KnowledgeBaseParams,
|
||||
item: KnowledgeItem,
|
||||
userId: string
|
||||
): Promise<FileMetadata> => {
|
||||
let fileToProcess: FileMetadata = file
|
||||
if (base.preprocessProvider && file.ext.toLowerCase() === '.pdf') {
|
||||
try {
|
||||
const provider = new PreprocessProvider(base.preprocessProvider.provider, userId)
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
// Check if file has already been preprocessed
|
||||
const alreadyProcessed = await provider.checkIfAlreadyProcessed(file)
|
||||
if (alreadyProcessed) {
|
||||
logger.debug(`File already preprocess processed, using cached result: ${filePath}`)
|
||||
return alreadyProcessed
|
||||
}
|
||||
|
||||
// Execute preprocessing
|
||||
logger.debug(`Starting preprocess processing for scanned PDF: ${filePath}`)
|
||||
const { processedFile, quota } = await provider.parseFile(item.id, file)
|
||||
fileToProcess = processedFile
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
mainWindow?.webContents.send('file-preprocess-finished', {
|
||||
itemId: item.id,
|
||||
quota: quota
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error(`Preprocess processing failed: ${err}`)
|
||||
// If preprocessing fails, use original file
|
||||
// fileToProcess = file
|
||||
throw new Error(`Preprocess processing failed: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
return fileToProcess
|
||||
}
|
||||
|
||||
public checkQuota = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
base: KnowledgeBaseParams,
|
||||
userId: string
|
||||
): Promise<number> => {
|
||||
try {
|
||||
if (base.preprocessProvider && base.preprocessProvider.type === 'preprocess') {
|
||||
const provider = new PreprocessProvider(base.preprocessProvider.provider, userId)
|
||||
return await provider.checkQuota()
|
||||
}
|
||||
throw new Error('No preprocess provider configured')
|
||||
} catch (err) {
|
||||
logger.error(`Failed to check quota: ${err}`)
|
||||
throw new Error(`Failed to check quota: ${err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new KnowledgeService()
|
||||
@ -235,7 +235,7 @@ class McpService {
|
||||
try {
|
||||
await inMemoryServer.connect(serverTransport)
|
||||
getServerLogger(server).debug(`In-memory server started`)
|
||||
} catch (error: Error | any) {
|
||||
} catch (error: any) {
|
||||
getServerLogger(server).error(`Error starting in-memory server`, error as Error)
|
||||
throw new Error(`Failed to start in-memory server: ${error.message}`)
|
||||
}
|
||||
@ -419,7 +419,7 @@ class McpService {
|
||||
const transport = await initTransport()
|
||||
try {
|
||||
await client.connect(transport)
|
||||
} catch (error: Error | any) {
|
||||
} catch (error: any) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))
|
||||
@ -852,7 +852,7 @@ class McpService {
|
||||
return {
|
||||
contents: contents
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
} catch (error: any) {
|
||||
getServerLogger(server, { uri }).error(`Failed to get resource`, error as Error)
|
||||
throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`)
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ export class MistralClientManager {
|
||||
private static instance: MistralClientManager
|
||||
private client: Mistral | null = null
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
// oxlint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): MistralClientManager {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Notification } from '@types'
|
||||
import { Notification as ElectronNotification } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
|
||||
@ -235,7 +235,7 @@ export class ProxyManager {
|
||||
https.request = this.bindHttpMethod(this.originalHttpsRequest, agent)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
// oxlint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
private bindHttpMethod(originalMethod: Function, agent: http.Agent | https.Agent) {
|
||||
return (...args: any[]) => {
|
||||
let url: string | URL | undefined
|
||||
|
||||
@ -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[]>
|
||||
}
|
||||
@ -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')
|
||||
)
|
||||
@ -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()
|
||||
@ -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 []
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,7 @@
|
||||
import { isLinux, isWin } from '@main/constant'
|
||||
import { loadOcrImage } from '@main/utils/ocr'
|
||||
import { OcrAccuracy, recognize } from '@napi-rs/system-ocr'
|
||||
import {
|
||||
ImageFileMetadata,
|
||||
isImageFileMetadata as isImageFileMetadata,
|
||||
OcrResult,
|
||||
OcrSystemConfig,
|
||||
SupportedOcrFile
|
||||
} from '@types'
|
||||
import { ImageFileMetadata, isImageFileMetadata, OcrResult, OcrSystemConfig, SupportedOcrFile } from '@types'
|
||||
|
||||
import { OcrBaseService } from './OcrBaseService'
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ export class FileServiceManager {
|
||||
private static instance: FileServiceManager
|
||||
private services: Map<string, BaseFileService> = new Map()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
// oxlint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): FileServiceManager {
|
||||
|
||||
@ -420,7 +420,7 @@ export function sanitizeFilename(fileName: string, replacement = '_'): string {
|
||||
|
||||
// 移除或替换非法字符
|
||||
let sanitized = fileName
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// oxlint-disable-next-line no-control-regex
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, replacement) // Windows 非法字符
|
||||
.replace(/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i, replacement + '$2') // Windows 保留名
|
||||
.replace(/[\s.]+$/, '') // 移除末尾的空格和点
|
||||
|
||||
@ -36,13 +36,14 @@ export function debounce(func: (...args: any[]) => void, wait: number, immediate
|
||||
}
|
||||
}
|
||||
|
||||
export function dumpPersistState() {
|
||||
const persistState = JSON.parse(localStorage.getItem('persist:cherry-studio') || '{}')
|
||||
for (const key in persistState) {
|
||||
persistState[key] = JSON.parse(persistState[key])
|
||||
}
|
||||
return JSON.stringify(persistState)
|
||||
}
|
||||
// NOTE: It's an unused function. localStorage should not be accessed in main process.
|
||||
// export function dumpPersistState() {
|
||||
// const persistState = JSON.parse(localStorage.getItem('persist:cherry-studio') || '{}')
|
||||
// for (const key in persistState) {
|
||||
// persistState[key] = JSON.parse(persistState[key])
|
||||
// }
|
||||
// return JSON.stringify(persistState)
|
||||
// }
|
||||
|
||||
export const runAsyncFunction = async (fn: () => void) => {
|
||||
await fn()
|
||||
|
||||
@ -5,6 +5,7 @@ import { UpgradeChannel } from '@shared/config/constant'
|
||||
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
|
||||
import type { FileChangeEvent } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import type { Notification } from '@types'
|
||||
import {
|
||||
AddMemoryOptions,
|
||||
AssistantMessage,
|
||||
@ -28,7 +29,6 @@ import {
|
||||
WebDavConfig
|
||||
} from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
import { CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
import type { ActionItem } from '../renderer/src/types/selectionTypes'
|
||||
@ -475,13 +475,10 @@ if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
console.error('[Preload]Failed to expose APIs:', error as Error)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import '@renderer/databases'
|
||||
|
||||
import { HeroUIProvider } from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import store, { persistor } from '@renderer/store'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
@ -11,6 +10,7 @@ import { ToastPortal } from './components/ToastPortal'
|
||||
import TopViewContainer from './components/TopView'
|
||||
import AntdProvider from './context/AntdProvider'
|
||||
import { CodeStyleProvider } from './context/CodeStyleProvider'
|
||||
import { HeroUIProvider } from './context/HeroUIProvider'
|
||||
import { NotificationProvider } from './context/NotificationProvider'
|
||||
import StyleSheetManager from './context/StyleSheetManager'
|
||||
import { ThemeProvider } from './context/ThemeProvider'
|
||||
@ -34,7 +34,7 @@ function App(): React.ReactElement {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HeroUIProvider className="flex h-full w-full flex-1">
|
||||
<HeroUIProvider>
|
||||
<StyleSheetManager>
|
||||
<ThemeProvider>
|
||||
<AntdProvider>
|
||||
|
||||
@ -314,7 +314,7 @@ export class AiSdkToChunkAdapter {
|
||||
// === 源和文件相关事件 ===
|
||||
case 'source':
|
||||
if (chunk.sourceType === 'url') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { sourceType: _, ...rest } = chunk
|
||||
final.webSearchResults.push(rest)
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ export abstract class BaseApiClient<
|
||||
* 用于判断客户端是否支持特定功能,避免instanceof检查的类型收窄问题
|
||||
* 对于装饰器模式的客户端(如AihubmixAPIClient),应该返回其内部实际使用的客户端类型
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public getClientCompatibilityType(_model?: Model): string[] {
|
||||
// 默认返回类的名称
|
||||
return [this.constructor.name]
|
||||
|
||||
@ -177,7 +177,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
}
|
||||
|
||||
// @ts-ignore sdk未提供
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
override async generateImage(generateImageParams: GenerateImageParams): Promise<string[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
@ -455,7 +455,7 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
}
|
||||
|
||||
// @ts-ignore sdk未提供
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
override async generateImage(_generateImageParams: GenerateImageParams): Promise<string[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ export class PPIOAPIClient extends OpenAIAPIClient {
|
||||
super(provider)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
override getClientCompatibilityType(_model?: Model): string[] {
|
||||
return ['OpenAIAPIClient']
|
||||
}
|
||||
|
||||
@ -44,7 +44,7 @@ const stringifyArgsForLogging = (args: any[]): string => {
|
||||
*/
|
||||
export const createGenericLoggingMiddleware: () => MethodMiddleware = () => {
|
||||
const middlewareName = 'GenericLoggingMiddleware'
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
return (_: MiddlewareAPI<BaseContext, any[]>) => (next) => async (ctx, args) => {
|
||||
const methodName = ctx.methodName
|
||||
const logPrefix = `[${middlewareName} (${methodName})]`
|
||||
|
||||
@ -66,6 +66,7 @@ class AdapterTracer {
|
||||
spanName: name,
|
||||
topicId: this.topicId,
|
||||
modelName: this.modelName,
|
||||
// oxlint-disable-next-line no-undef False alarm. see https://github.com/oxc-project/oxc/issues/4232
|
||||
argCount: arguments.length
|
||||
})
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService'
|
||||
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
|
||||
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||
import type { ModelMessage } from 'ai'
|
||||
import type { ModelMessage, Tool } from 'ai'
|
||||
import { stepCountIs } from 'ai'
|
||||
|
||||
import { getAiSdkProviderId } from '../provider/factory'
|
||||
@ -29,6 +29,8 @@ import { getTemperature, getTopP } from './modelParameters'
|
||||
|
||||
const logger = loggerService.withContext('parameterBuilder')
|
||||
|
||||
type ProviderDefinedTool = Extract<Tool<any, any>, { type: 'provider-defined' }>
|
||||
|
||||
/**
|
||||
* 构建 AI SDK 流式参数
|
||||
* 这是主要的参数构建函数,整合所有转换逻辑
|
||||
@ -113,9 +115,9 @@ export async function buildStreamTextParams(
|
||||
tools = {}
|
||||
}
|
||||
if (aiSdkProviderId === 'google-vertex') {
|
||||
tools.google_search = vertex.tools.googleSearch({})
|
||||
tools.google_search = vertex.tools.googleSearch({}) as ProviderDefinedTool
|
||||
} else if (aiSdkProviderId === 'google-vertex-anthropic') {
|
||||
tools.web_search = vertexAnthropic.tools.webSearch_20250305({})
|
||||
tools.web_search = vertexAnthropic.tools.webSearch_20250305({}) as ProviderDefinedTool
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,7 +126,7 @@ export async function buildStreamTextParams(
|
||||
if (!tools) {
|
||||
tools = {}
|
||||
}
|
||||
tools.url_context = vertex.tools.urlContext({})
|
||||
tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool
|
||||
}
|
||||
|
||||
// 构建基础参数
|
||||
|
||||
@ -9,7 +9,7 @@ import { JSONSchema7 } from 'json-schema'
|
||||
const logger = loggerService.withContext('MCP-utils')
|
||||
|
||||
// Setup tools configuration based on provided parameters
|
||||
export function setupToolsConfig(mcpTools?: MCPTool[]): Record<string, Tool> | undefined {
|
||||
export function setupToolsConfig(mcpTools?: MCPTool[]): Record<string, Tool<any, any>> | undefined {
|
||||
let tools: ToolSet = {}
|
||||
|
||||
if (!mcpTools?.length) {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
.command-list-popover {
|
||||
/* Base styles are handled inline for theme support */
|
||||
|
||||
/* Arrow styles based on placement */
|
||||
}
|
||||
|
||||
|
||||
@ -121,6 +121,7 @@
|
||||
border-radius: 5px;
|
||||
word-break: keep-all;
|
||||
white-space: pre;
|
||||
text-wrap: wrap;
|
||||
}
|
||||
|
||||
.markdown code {
|
||||
|
||||
@ -53,6 +53,7 @@
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
--icon: #00000099;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@ -87,6 +88,7 @@
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
--icon: #ffffff99;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@ -128,6 +130,7 @@
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--animate-marquee: marquee var(--duration) infinite linear;
|
||||
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
|
||||
--color-icon: var(--icon);
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
|
||||
30
src/renderer/src/components/Buttons/ActionIconButton.tsx
Normal file
30
src/renderer/src/components/Buttons/ActionIconButton.tsx
Normal 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)
|
||||
1
src/renderer/src/components/Buttons/index.ts
Normal file
1
src/renderer/src/components/Buttons/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as ActionIconButton } from './ActionIconButton'
|
||||
@ -1,4 +1,4 @@
|
||||
import { ToolbarButton } from '@renderer/pages/home/Inputbar/Inputbar'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
|
||||
import { Tooltip } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
@ -364,23 +364,23 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
<ToolBar>
|
||||
{showUserToggle && (
|
||||
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
|
||||
<ActionIconButton onClick={userOutlinedButtonOnClick}>
|
||||
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
|
||||
<ActionIconButton onClick={caseSensitiveButtonOnClick}>
|
||||
<CaseSensitive
|
||||
size={18}
|
||||
style={{ color: isCaseSensitive ? 'var(--color-link)' : 'var(--color-icon)' }}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('button.whole_word')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={wholeWordButtonOnClick}>
|
||||
<ActionIconButton onClick={wholeWordButtonOnClick}>
|
||||
<WholeWord size={18} style={{ color: isWholeWord ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
</ToolBar>
|
||||
</InputWrapper>
|
||||
@ -397,15 +397,15 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
)}
|
||||
</SearchResults>
|
||||
<ToolBar>
|
||||
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={allRanges.length === 0}>
|
||||
<ActionIconButton onClick={prevButtonOnClick} disabled={allRanges.length === 0}>
|
||||
<ChevronUp size={18} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={allRanges.length === 0}>
|
||||
</ActionIconButton>
|
||||
<ActionIconButton onClick={nextButtonOnClick} disabled={allRanges.length === 0}>
|
||||
<ChevronDown size={18} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton type="text" onClick={closeButtonOnClick}>
|
||||
</ActionIconButton>
|
||||
<ActionIconButton onClick={closeButtonOnClick}>
|
||||
<X size={18} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</ToolBar>
|
||||
</SearchBarContainer>
|
||||
</NarrowLayout>
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import WindowControls from '@renderer/components/WindowControls'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { isDev, isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useBridge } from '@renderer/hooks/useBridge'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
@ -170,8 +170,6 @@ const MinappPopupContainer: React.FC = () => {
|
||||
|
||||
const { isLeftNavbar } = useNavbarPosition()
|
||||
|
||||
const isInDevelopment = process.env.NODE_ENV === 'development'
|
||||
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
useBridge()
|
||||
@ -477,7 +475,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
<LinkOutlined />
|
||||
</TitleButton>
|
||||
</Tooltip>
|
||||
{isInDevelopment && (
|
||||
{isDev && (
|
||||
<Tooltip title={t('minapp.popup.devtools')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<TitleButton onClick={() => handleOpenDevTools(appInfo.id)}>
|
||||
<CodeOutlined />
|
||||
|
||||
@ -36,7 +36,7 @@ export const OGCard = ({ link, show }: Props) => {
|
||||
const GeneratedGraph = useCallback(() => {
|
||||
return (
|
||||
<div className="flex h-36 items-center justify-center bg-accent p-4">
|
||||
<h2 className="text-2xl font-bold">{metadata['og:title'] || hostname}</h2>
|
||||
<h2 className="font-bold text-2xl">{metadata['og:title'] || hostname}</h2>
|
||||
</div>
|
||||
)
|
||||
}, [hostname, metadata])
|
||||
|
||||
@ -1,5 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
export enum QuickPanelReservedSymbol {
|
||||
Root = '/',
|
||||
File = 'file',
|
||||
KnowledgeBase = '#',
|
||||
MentionModels = '@',
|
||||
QuickPhrases = 'quick-phrases',
|
||||
Thinking = 'thinking',
|
||||
WebSearch = '?',
|
||||
Mcp = 'mcp',
|
||||
McpPrompt = 'mcp-prompt',
|
||||
McpResource = 'mcp-resource'
|
||||
}
|
||||
|
||||
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
|
||||
export type QuickPanelTriggerInfo = {
|
||||
type: 'input' | 'button'
|
||||
|
||||
@ -341,11 +341,12 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
scrollTriggerRef.current = 'none'
|
||||
}, [index])
|
||||
|
||||
// 处理键盘事件(折叠时不拦截全局键盘)
|
||||
// 处理键盘事件:
|
||||
// - 可见且未折叠时:拦截 Enter 及其组合键(纯 Enter 选择项;带修饰键仅拦截不处理)。
|
||||
// - 软隐藏/折叠时:不拦截 Enter,允许输入框处理(用于发送消息等)。
|
||||
// - 不可见时:不拦截,输入框按常规处理。
|
||||
useEffect(() => {
|
||||
const hasSearchTextFlag = searchText.replace(/^[/@]/, '').length > 0
|
||||
const isCollapsed = hasSearchTextFlag && list.length === 0
|
||||
if (!ctx.isVisible || isCollapsed) return
|
||||
if (!ctx.isVisible) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isMac ? e.metaKey : e.ctrlKey) {
|
||||
@ -438,9 +439,24 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
break
|
||||
|
||||
case 'Enter':
|
||||
case 'NumpadEnter':
|
||||
case 'NumpadEnter': {
|
||||
if (isComposing.current) return
|
||||
|
||||
// 折叠/软隐藏时不拦截,让输入框处理(用于发送消息)
|
||||
const hasSearch = searchText.replace(/^[/@]/, '').length > 0
|
||||
const nonPinnedCount = list.filter((i) => !i.alwaysVisible).length
|
||||
const isCollapsed = hasSearch && nonPinnedCount === 0
|
||||
if (isCollapsed) return
|
||||
|
||||
// 面板可见且未折叠时:拦截所有 Enter 变体;
|
||||
// 纯 Enter 选择项,带修饰键仅拦截不处理
|
||||
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsMouseOver(false)
|
||||
break
|
||||
}
|
||||
|
||||
if (list?.[index]) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@ -451,6 +467,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
handleClose('enter_empty')
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Escape':
|
||||
e.stopPropagation()
|
||||
handleClose('esc')
|
||||
|
||||
@ -43,7 +43,7 @@ const CustomTag: FC<CustomTagProps> = ({
|
||||
...(disabled && { cursor: 'not-allowed' }),
|
||||
...style
|
||||
}}>
|
||||
{icon && icon} {children}
|
||||
{icon} {children}
|
||||
{closable && (
|
||||
<CloseIcon
|
||||
$size={size}
|
||||
|
||||
13
src/renderer/src/context/HeroUIProvider.tsx
Normal file
13
src/renderer/src/context/HeroUIProvider.tsx
Normal 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 }
|
||||
@ -31,7 +31,7 @@ const tailwindThemeChange = (theme: ThemeMode) => {
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
// 用户设置的主题
|
||||
const { theme: settedTheme, setTheme: setSettedTheme } = useSettings()
|
||||
const { theme: settedTheme, setTheme: setSettedTheme, language } = useSettings()
|
||||
const [actualTheme, setActualTheme] = useState<ThemeMode>(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches ? ThemeMode.dark : ThemeMode.light
|
||||
)
|
||||
@ -59,6 +59,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
document.body.classList.add('light')
|
||||
}
|
||||
document.body.setAttribute('navbar-position', navbarPosition)
|
||||
document.documentElement.lang = language
|
||||
|
||||
// if theme is old auto, then set theme to system
|
||||
// we can delete this after next big release
|
||||
@ -73,7 +74,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
document.body.setAttribute('theme-mode', actualTheme)
|
||||
setActualTheme(actualTheme)
|
||||
})
|
||||
}, [actualTheme, initUserTheme, navbarPosition, setSettedTheme, settedTheme])
|
||||
}, [actualTheme, initUserTheme, language, navbarPosition, setSettedTheme, settedTheme])
|
||||
|
||||
useEffect(() => {
|
||||
tailwindThemeChange(actualTheme)
|
||||
|
||||
@ -172,7 +172,7 @@ export function useAssistant(id: string) {
|
||||
(model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })),
|
||||
[assistant, dispatch]
|
||||
),
|
||||
updateAssistant: (assistant: Assistant) => dispatch(updateAssistant(assistant)),
|
||||
updateAssistant: useCallback((assistant: Partial<Assistant>) => dispatch(updateAssistant(assistant)), [dispatch]),
|
||||
updateAssistantSettings
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,7 +24,6 @@ import {
|
||||
KnowledgeBase,
|
||||
KnowledgeItem,
|
||||
KnowledgeNoteItem,
|
||||
MigrationModeEnum,
|
||||
ProcessingStatus
|
||||
} from '@renderer/types'
|
||||
import { runAsyncFunction, uuid } from '@renderer/utils'
|
||||
@ -231,7 +230,7 @@ export const useKnowledge = (baseId: string) => {
|
||||
}
|
||||
|
||||
// 迁移知识库(保留原知识库)
|
||||
const migrateBase = async (newBase: KnowledgeBase, mode: MigrationModeEnum) => {
|
||||
const migrateBase = async (newBase: KnowledgeBase) => {
|
||||
if (!base) return
|
||||
|
||||
const timestamp = dayjs().format('YYMMDDHHmmss')
|
||||
@ -244,14 +243,9 @@ export const useKnowledge = (baseId: string) => {
|
||||
name: newName,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
items: [],
|
||||
framework: mode === MigrationModeEnum.MigrationToLangChain ? 'langchain' : base.framework
|
||||
items: []
|
||||
} satisfies KnowledgeBase
|
||||
|
||||
if (mode === MigrationModeEnum.MigrationToLangChain) {
|
||||
await window.api.knowledgeBase.create(getKnowledgeBaseParams(migratedBase))
|
||||
}
|
||||
|
||||
dispatch(addBase(migratedBase))
|
||||
|
||||
const files: FileMetadata[] = []
|
||||
|
||||
@ -14,11 +14,7 @@ const createInitialKnowledgeBase = (): KnowledgeBase => ({
|
||||
items: [],
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
version: 1,
|
||||
framework: 'langchain',
|
||||
retriever: {
|
||||
mode: 'hybrid'
|
||||
}
|
||||
version: 1
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@ -381,8 +381,9 @@
|
||||
"translate": "Translate to {{target_language}}",
|
||||
"translating": "Translating...",
|
||||
"upload": {
|
||||
"attachment": "Upload attachment",
|
||||
"document": "Upload document file (model does not support images)",
|
||||
"label": "Upload image or document file",
|
||||
"image_or_document": "Upload image or document file",
|
||||
"upload_from_local": "Upload local file..."
|
||||
},
|
||||
"url_context": "URL Context",
|
||||
@ -1098,10 +1099,6 @@
|
||||
"error": {
|
||||
"failed": "Migration failed"
|
||||
},
|
||||
"migrate_to_langchain": {
|
||||
"content": "The knowledge base migration does not delete the old knowledge base but creates a copy and reprocesses all entries, which may consume a significant number of tokens. Please proceed with caution.",
|
||||
"info": "The knowledge base architecture has been updated. Click to migrate to the new architecture."
|
||||
},
|
||||
"source_dimensions": "Source Dimensions",
|
||||
"source_model": "Source Model",
|
||||
"target_dimensions": "Target Dimensions",
|
||||
@ -1120,20 +1117,6 @@
|
||||
"quota": "{{name}} Left Quota: {{quota}}",
|
||||
"quota_infinity": "{{name}} Quota: Unlimited",
|
||||
"rename": "Rename",
|
||||
"retriever": "Retrieve mode",
|
||||
"retriever_bm25": "full-text search",
|
||||
"retriever_bm25_desc": "Search for documents based on keyword relevance and frequency.",
|
||||
"retriever_hybrid": "Hybrid Search (Recommended)",
|
||||
"retriever_hybrid_desc": "Combine keyword search and semantic search to achieve optimal retrieval accuracy.",
|
||||
"retriever_hybrid_weight": {
|
||||
"bm25": "full text",
|
||||
"recommended": "recommend",
|
||||
"title": "Hybrid Search Weight Adjustment (Full-text/Vector)",
|
||||
"vector": "vector"
|
||||
},
|
||||
"retriever_tooltip": "Using different retrieval methods to search the knowledge base",
|
||||
"retriever_vector": "vector search",
|
||||
"retriever_vector_desc": "Retrieve documents based on semantic similarity and meaning.",
|
||||
"search": "Search knowledge base",
|
||||
"search_placeholder": "Enter text to search",
|
||||
"settings": {
|
||||
|
||||
@ -381,8 +381,9 @@
|
||||
"translate": "翻译成 {{target_language}}",
|
||||
"translating": "翻译中...",
|
||||
"upload": {
|
||||
"attachment": "上传附件",
|
||||
"document": "上传文档(模型不支持图片)",
|
||||
"label": "上传图片或文档",
|
||||
"image_or_document": "上传图片或文档",
|
||||
"upload_from_local": "上传本地文件..."
|
||||
},
|
||||
"url_context": "网页上下文",
|
||||
@ -1098,10 +1099,6 @@
|
||||
"error": {
|
||||
"failed": "迁移失败"
|
||||
},
|
||||
"migrate_to_langchain": {
|
||||
"content": "知识库迁移不会删除旧知识库,而是创建一个副本之后重新处理所有知识库条目,可能消耗大量 tokens,请谨慎操作。",
|
||||
"info": "知识库架构已更新,点击迁移到新架构"
|
||||
},
|
||||
"source_dimensions": "源维度",
|
||||
"source_model": "源模型",
|
||||
"target_dimensions": "目标维度",
|
||||
@ -1120,20 +1117,6 @@
|
||||
"quota": "{{name}} 剩余额度:{{quota}}",
|
||||
"quota_infinity": "{{name}} 剩余额度:无限制",
|
||||
"rename": "重命名",
|
||||
"retriever": "检索模式",
|
||||
"retriever_bm25": "全文搜索",
|
||||
"retriever_bm25_desc": "根据关键字的相关性和频率查找文档。",
|
||||
"retriever_hybrid": "混合搜索 (推荐)",
|
||||
"retriever_hybrid_desc": "结合关键词搜索和语义搜索,以实现最佳检索准确性。",
|
||||
"retriever_hybrid_weight": {
|
||||
"bm25": "全文",
|
||||
"recommended": "推荐",
|
||||
"title": "混合搜索权重调整 (全文/向量)",
|
||||
"vector": "向量"
|
||||
},
|
||||
"retriever_tooltip": "使用不同的检索方式检索知识库",
|
||||
"retriever_vector": "向量搜索",
|
||||
"retriever_vector_desc": "根据语义相似性和含义查找文档。",
|
||||
"search": "搜索知识库",
|
||||
"search_placeholder": "输入查询内容",
|
||||
"settings": {
|
||||
|
||||
@ -381,8 +381,9 @@
|
||||
"translate": "翻譯成 {{target_language}}",
|
||||
"translating": "翻譯中...",
|
||||
"upload": {
|
||||
"attachment": "上傳附件",
|
||||
"document": "上傳文件(模型不支援圖片)",
|
||||
"label": "上傳圖片或文件",
|
||||
"image_or_document": "上傳圖片或文件",
|
||||
"upload_from_local": "上傳本地文件..."
|
||||
},
|
||||
"url_context": "網頁上下文",
|
||||
@ -1098,10 +1099,6 @@
|
||||
"error": {
|
||||
"failed": "遷移失敗"
|
||||
},
|
||||
"migrate_to_langchain": {
|
||||
"content": "知識庫遷移不會刪除舊知識庫,而是建立一個副本後重新處理所有知識庫條目,可能消耗大量 tokens,請謹慎操作。",
|
||||
"info": "知識庫架構已更新,點擊遷移到新架構"
|
||||
},
|
||||
"source_dimensions": "源維度",
|
||||
"source_model": "源模型",
|
||||
"target_dimensions": "目標維度",
|
||||
@ -1120,20 +1117,6 @@
|
||||
"quota": "{{name}} 剩餘配額:{{quota}}",
|
||||
"quota_infinity": "{{name}} 配額:無限制",
|
||||
"rename": "重新命名",
|
||||
"retriever": "搜尋模式",
|
||||
"retriever_bm25": "全文搜尋",
|
||||
"retriever_bm25_desc": "根據關鍵字的相關性和頻率查找文件。",
|
||||
"retriever_hybrid": "混合搜尋(推薦)",
|
||||
"retriever_hybrid_desc": "結合關鍵字搜索和語義搜索,以實現最佳檢索準確性。",
|
||||
"retriever_hybrid_weight": {
|
||||
"bm25": "全文",
|
||||
"recommended": "推薦",
|
||||
"title": "混合搜尋權重調整 (全文/向量)",
|
||||
"vector": "向量"
|
||||
},
|
||||
"retriever_tooltip": "使用不同的檢索方式檢索知識庫",
|
||||
"retriever_vector": "向量搜尋",
|
||||
"retriever_vector_desc": "根據語意相似性和含義查找文件。",
|
||||
"search": "搜尋知識庫",
|
||||
"search_placeholder": "輸入查詢內容",
|
||||
"settings": {
|
||||
|
||||
@ -1098,10 +1098,6 @@
|
||||
"error": {
|
||||
"failed": "Αποτυχία μεταφοράς"
|
||||
},
|
||||
"migrate_to_langchain": {
|
||||
"content": "Η μετανάστευση της βάσης γνώσεων δεν διαγράφει την παλιά βάση γνώσεων, αλλά δημιουργεί ένα αντίγραφο και στη συνέχεια επεξεργάζεται ξανά όλες τις εγγραφές της βάσης γνώσεων, κάτι που μπορεί να καταναλώσει μεγάλο αριθμό tokens, οπότε ενεργήστε με προσοχή.",
|
||||
"info": "Η δομή της βάσης γνώσεων έχει ενημερωθεί, κάντε κλικ για μετεγκατάσταση στη νέα δομή"
|
||||
},
|
||||
"source_dimensions": "Πηγαίες διαστάσεις",
|
||||
"source_model": "Πηγαίο μοντέλο",
|
||||
"target_dimensions": "Προορισμένες διαστάσεις",
|
||||
@ -1120,20 +1116,6 @@
|
||||
"quota": "Διαθέσιμο όριο για {{name}}: {{quota}}",
|
||||
"quota_infinity": "Διαθέσιμο όριο για {{name}}: Απεριόριστο",
|
||||
"rename": "Μετονομασία",
|
||||
"retriever": "Λειτουργία αναζήτησης",
|
||||
"retriever_bm25": "Πλήρης αναζήτηση κειμένου",
|
||||
"retriever_bm25_desc": "Αναζήτηση εγγράφων με βάση τη σχετικότητα και τη συχνότητα των λέξεων-κλειδιών.",
|
||||
"retriever_hybrid": "Μικτή αναζήτηση (συνιστάται)",
|
||||
"retriever_hybrid_desc": "Συνδυάστε την αναζήτηση με λέξεις-κλειδιά και την σημασιολογική αναζήτηση για την επίτευξη της βέλτιστης ακρίβειας ανάκτησης.",
|
||||
"retriever_hybrid_weight": {
|
||||
"bm25": "ολόκληρο το κείμενο",
|
||||
"recommended": "Προτείνω",
|
||||
"title": "Προσαρμογή βάρους μικτής αναζήτησης (πλήρες κείμενο/διανυσματικό)",
|
||||
"vector": "διάνυσμα"
|
||||
},
|
||||
"retriever_tooltip": "Χρησιμοποιώντας διαφορετικές μεθόδους αναζήτησης για αναζήτηση στη βάση γνώσης",
|
||||
"retriever_vector": "Αναζήτηση διανυσμάτων",
|
||||
"retriever_vector_desc": "Βρείτε έγγραφα βάση της σημασιολογικής ομοιότητας και της έννοιας.",
|
||||
"search": "Αναζήτηση βάσης γνώσεων",
|
||||
"search_placeholder": "Εισάγετε την αναζήτηση",
|
||||
"settings": {
|
||||
|
||||
@ -1098,10 +1098,6 @@
|
||||
"error": {
|
||||
"failed": "Error en la migración"
|
||||
},
|
||||
"migrate_to_langchain": {
|
||||
"content": "La migración de la base de conocimiento no elimina la base antigua, sino que crea una copia y luego reprocesa todas las entradas, lo que puede consumir una gran cantidad de tokens. Proceda con precaución.",
|
||||
"info": "La estructura de la base de conocimiento ha sido actualizada. Haz clic para migrar a la nueva estructura."
|
||||
},
|
||||
"source_dimensions": "Dimensiones de origen",
|
||||
"source_model": "Modelo de origen",
|
||||
"target_dimensions": "Dimensiones de destino",
|
||||
@ -1120,20 +1116,6 @@
|
||||
"quota": "Cupo restante de {{name}}: {{quota}}",
|
||||
"quota_infinity": "Cupo restante de {{name}}: ilimitado",
|
||||
"rename": "Renombrar",
|
||||
"retriever": "modo de recuperación",
|
||||
"retriever_bm25": "búsqueda de texto completo",
|
||||
"retriever_bm25_desc": "Encontrar documentos basados en la relevancia y frecuencia de las palabras clave.",
|
||||
"retriever_hybrid": "Búsqueda híbrida (recomendada)",
|
||||
"retriever_hybrid_desc": "Combinar la búsqueda por palabras clave con la búsqueda semántica para lograr la máxima precisión en la recuperación.",
|
||||
"retriever_hybrid_weight": {
|
||||
"bm25": "texto completo",
|
||||
"recommended": "Recomendado",
|
||||
"title": "Ajuste de ponderación en búsqueda híbrida (texto completo/vectorial)",
|
||||
"vector": "vector"
|
||||
},
|
||||
"retriever_tooltip": "Usar diferentes métodos de búsqueda para consultar la base de conocimiento",
|
||||
"retriever_vector": "búsqueda vectorial",
|
||||
"retriever_vector_desc": "Buscar documentos según similitud semántica y significado.",
|
||||
"search": "Buscar en la base de conocimientos",
|
||||
"search_placeholder": "Ingrese el contenido de la consulta",
|
||||
"settings": {
|
||||
|
||||
@ -1098,10 +1098,6 @@
|
||||
"error": {
|
||||
"failed": "Erreur lors de la migration"
|
||||
},
|
||||
"migrate_to_langchain": {
|
||||
"content": "La migration de la base de connaissances ne supprime pas l'ancienne base, mais crée une copie avant de retraiter tous les éléments, ce qui peut consommer un grand nombre de tokens. Veuillez agir avec prudence.",
|
||||
"info": "L'architecture de la base de connaissances a été mise à jour, cliquez pour migrer vers la nouvelle architecture."
|
||||
},
|
||||
"source_dimensions": "Dimensions source",
|
||||
"source_model": "Modèle source",
|
||||
"target_dimensions": "Dimensions cible",
|
||||
@ -1120,20 +1116,6 @@
|
||||
"quota": "Quota restant pour {{name}} : {{quota}}",
|
||||
"quota_infinity": "Quota restant pour {{name}} : illimité",
|
||||
"rename": "Renommer",
|
||||
"retriever": "Mode de recherche",
|
||||
"retriever_bm25": "Recherche plein texte",
|
||||
"retriever_bm25_desc": "Rechercher des documents en fonction de la pertinence et de la fréquence des mots-clés.",
|
||||
"retriever_hybrid": "Recherche hybride (recommandé)",
|
||||
"retriever_hybrid_desc": "Associez la recherche par mots-clés et la recherche sémantique pour une précision de recherche optimale.",
|
||||
"retriever_hybrid_weight": {
|
||||
"bm25": "texte intégral",
|
||||
"recommended": "Recommandé",
|
||||
"title": "Ajustement des pondérations de recherche hybride (texte intégral/vecteur)",
|
||||
"vector": "vecteur"
|
||||
},
|
||||
"retriever_tooltip": "Utiliser différentes méthodes de recherche pour interroger la base de connaissances",
|
||||
"retriever_vector": "Recherche vectorielle",
|
||||
"retriever_vector_desc": "Rechercher des documents selon la similarité sémantique et le sens.",
|
||||
"search": "Rechercher dans la base de connaissances",
|
||||
"search_placeholder": "Entrez votre requête",
|
||||
"settings": {
|
||||
|
||||
@ -1098,10 +1098,6 @@
|
||||
"error": {
|
||||
"failed": "移行が失敗しました"
|
||||
},
|
||||
"migrate_to_langchain": {
|
||||
"content": "ナレッジベースの移行は旧ナレッジベースを削除せず、すべてのエントリーを再処理したコピーを作成します。大量のトークンを消費する可能性があるため、操作には十分注意してください。",
|
||||
"info": "ナレッジベースのアーキテクチャが更新されました、新しいアーキテクチャに移行するにはクリックしてください"
|
||||
},
|
||||
"source_dimensions": "ソース次元",
|
||||
"source_model": "ソースモデル",
|
||||
"target_dimensions": "ターゲット次元",
|
||||
@ -1120,20 +1116,6 @@
|
||||
"quota": "{{name}} 残りクォータ: {{quota}}",
|
||||
"quota_infinity": "{{name}} クォータ: 無制限",
|
||||
"rename": "名前を変更",
|
||||
"retriever": "検索モード",
|
||||
"retriever_bm25": "全文検索",
|
||||
"retriever_bm25_desc": "キーワードの関連性と頻度に基づいてドキュメントを検索します。",
|
||||
"retriever_hybrid": "ハイブリッド検索(おすすめ)",
|
||||
"retriever_hybrid_desc": "キーワード検索と意味検索を組み合わせて、最高の検索精度を実現します。",
|
||||
"retriever_hybrid_weight": {
|
||||
"bm25": "全文(ぜんぶん)",
|
||||
"recommended": "おすすめ",
|
||||
"title": "ハイブリッド検索の重み付け調整 (全文/ベクトル)",
|
||||
"vector": "ベクトル"
|
||||
},
|
||||
"retriever_tooltip": "異なる検索方法を使用してナレッジベースを検索する",
|
||||
"retriever_vector": "ベクトル検索",
|
||||
"retriever_vector_desc": "意味的な類似性と意味に基づいて文書を検索します。",
|
||||
"search": "ナレッジベースを検索",
|
||||
"search_placeholder": "検索するテキストを入力",
|
||||
"settings": {
|
||||
|
||||
@ -1098,10 +1098,6 @@
|
||||
"error": {
|
||||
"failed": "Falha na migração"
|
||||
},
|
||||
"migrate_to_langchain": {
|
||||
"content": "A migração da base de conhecimento não elimina a base antiga, mas sim cria uma cópia e reprocessa todas as entradas, o que pode consumir muitos tokens. Por favor, proceda com cautela.",
|
||||
"info": "A arquitetura da base de conhecimento foi atualizada, clique para migrar para a nova arquitetura."
|
||||
},
|
||||
"source_dimensions": "Dimensões de origem",
|
||||
"source_model": "Modelo de origem",
|
||||
"target_dimensions": "Dimensões de destino",
|
||||
@ -1120,20 +1116,6 @@
|
||||
"quota": "Cota restante de {{name}}: {{quota}}",
|
||||
"quota_infinity": "Cota restante de {{name}}: ilimitada",
|
||||
"rename": "Renomear",
|
||||
"retriever": "Modo de pesquisa",
|
||||
"retriever_bm25": "pesquisa de texto completo",
|
||||
"retriever_bm25_desc": "Pesquisar documentos com base na relevância e frequência das palavras-chave.",
|
||||
"retriever_hybrid": "Pesquisa híbrida (recomendada)",
|
||||
"retriever_hybrid_desc": "Combine a pesquisa por palavras-chave com a pesquisa semântica para alcançar a melhor precisão de recuperação.",
|
||||
"retriever_hybrid_weight": {
|
||||
"bm25": "texto integral",
|
||||
"recommended": "Recomendar",
|
||||
"title": "Ajuste de ponderação de pesquisa híbrida (texto completo/vetorial)",
|
||||
"vector": "vetor"
|
||||
},
|
||||
"retriever_tooltip": "Utilize diferentes métodos de pesquisa para consultar a base de conhecimento.",
|
||||
"retriever_vector": "pesquisa vetorial",
|
||||
"retriever_vector_desc": "Encontrar documentos com base na similaridade semântica e significado.",
|
||||
"search": "Pesquisar repositório de conhecimento",
|
||||
"search_placeholder": "Digite o conteúdo da consulta",
|
||||
"settings": {
|
||||
|
||||
@ -1098,10 +1098,6 @@
|
||||
"error": {
|
||||
"failed": "Миграция завершена с ошибками"
|
||||
},
|
||||
"migrate_to_langchain": {
|
||||
"content": "Миграция базы знаний не удаляет старую базу, а создает ее копию с последующей повторной обработкой всех записей, что может потребовать значительного количества токенов. Пожалуйста, действуйте осторожно.",
|
||||
"info": "Архитектура базы знаний обновлена, нажмите, чтобы перейти на новую архитектуру"
|
||||
},
|
||||
"source_dimensions": "Исходная размерность",
|
||||
"source_model": "Исходная модель",
|
||||
"target_dimensions": "Целевая размерность",
|
||||
@ -1120,20 +1116,6 @@
|
||||
"quota": "{{name}} Остаток квоты: {{quota}}",
|
||||
"quota_infinity": "{{name}} Квота: Не ограничена",
|
||||
"rename": "Переименовать",
|
||||
"retriever": "Режим поиска",
|
||||
"retriever_bm25": "полнотекстовый поиск",
|
||||
"retriever_bm25_desc": "Поиск документов на основе релевантности и частоты ключевых слов.",
|
||||
"retriever_hybrid": "Гибридный поиск (рекомендуется)",
|
||||
"retriever_hybrid_desc": "Сочетание поиска по ключевым словам и семантического поиска для достижения оптимальной точности поиска.",
|
||||
"retriever_hybrid_weight": {
|
||||
"bm25": "Полный текст",
|
||||
"recommended": "рекомендовать",
|
||||
"title": "Регулировка весов гибридного поиска (полнотекстовый/векторный)",
|
||||
"vector": "вектор"
|
||||
},
|
||||
"retriever_tooltip": "Использование различных методов поиска в базе знаний",
|
||||
"retriever_vector": "векторный поиск",
|
||||
"retriever_vector_desc": "Поиск документов по семантическому сходству и смыслу.",
|
||||
"search": "Поиск в базе знаний",
|
||||
"search_placeholder": "Введите текст для поиска",
|
||||
"settings": {
|
||||
|
||||
@ -14,6 +14,7 @@ import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setIsBunInstalled } from '@renderer/store/mcp'
|
||||
import { Model } from '@renderer/types'
|
||||
import { getClaudeSupportedProviders } from '@renderer/utils/provider'
|
||||
import { codeTools } from '@shared/config/constant'
|
||||
import { Alert, Avatar, Button, Checkbox, Input, Popover, Select, Space } from 'antd'
|
||||
import { ArrowUpRight, Download, HelpCircle, Terminal, X } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@ -26,6 +27,7 @@ import {
|
||||
CLI_TOOL_PROVIDER_MAP,
|
||||
CLI_TOOLS,
|
||||
generateToolEnvironment,
|
||||
OPENAI_CODEX_SUPPORTED_PROVIDERS,
|
||||
parseEnvironmentVariables
|
||||
} from '.'
|
||||
|
||||
@ -65,12 +67,15 @@ const CodeToolsPage: FC = () => {
|
||||
if (m.provider === 'cherryin') {
|
||||
return false
|
||||
}
|
||||
if (selectedCliTool === 'claude-code') {
|
||||
if (selectedCliTool === codeTools.claudeCode) {
|
||||
return m.id.includes('claude') || CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS.includes(m.provider)
|
||||
}
|
||||
if (selectedCliTool === 'gemini-cli') {
|
||||
if (selectedCliTool === codeTools.geminiCli) {
|
||||
return m.id.includes('gemini')
|
||||
}
|
||||
if (selectedCliTool === codeTools.openaiCodex) {
|
||||
return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider)
|
||||
}
|
||||
return true
|
||||
},
|
||||
[selectedCliTool]
|
||||
@ -153,8 +158,8 @@ const CodeToolsPage: FC = () => {
|
||||
|
||||
const modelProvider = getProviderByModel(selectedModel)
|
||||
const aiProvider = new AiProvider(modelProvider)
|
||||
const baseUrl = await aiProvider.getBaseURL()
|
||||
const apiKey = await aiProvider.getApiKey()
|
||||
const baseUrl = aiProvider.getBaseURL()
|
||||
const apiKey = aiProvider.getApiKey()
|
||||
|
||||
// 生成工具特定的环境变量
|
||||
const toolEnv = generateToolEnvironment({
|
||||
@ -173,7 +178,9 @@ const CodeToolsPage: FC = () => {
|
||||
|
||||
// 执行启动操作
|
||||
const executeLaunch = async (env: Record<string, string>) => {
|
||||
window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, { autoUpdateToLatest })
|
||||
window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, {
|
||||
autoUpdateToLatest
|
||||
})
|
||||
window.toast.success(t('code.launch.success'))
|
||||
}
|
||||
|
||||
@ -197,7 +204,7 @@ const CodeToolsPage: FC = () => {
|
||||
|
||||
await executeLaunch(env)
|
||||
} catch (error) {
|
||||
logger.error('启动失败:', error as Error)
|
||||
logger.error('start code tools failed:', error as Error)
|
||||
window.toast.error(t('code.launch.error'))
|
||||
} finally {
|
||||
setIsLaunching(false)
|
||||
|
||||
@ -25,6 +25,7 @@ export const CLI_TOOLS = [
|
||||
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api']
|
||||
export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = ['deepseek', 'moonshot', 'zhipu', 'dashscope', 'modelscope']
|
||||
export const CLAUDE_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', ...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS]
|
||||
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api']
|
||||
|
||||
// Provider 过滤映射
|
||||
export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Provider[]> = {
|
||||
@ -33,7 +34,8 @@ export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Pr
|
||||
[codeTools.geminiCli]: (providers) =>
|
||||
providers.filter((p) => p.type === 'gemini' || GEMINI_SUPPORTED_PROVIDERS.includes(p.id)),
|
||||
[codeTools.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')),
|
||||
[codeTools.openaiCodex]: (providers) => providers.filter((p) => p.id === 'openai')
|
||||
[codeTools.openaiCodex]: (providers) =>
|
||||
providers.filter((p) => p.id === 'openai' || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id))
|
||||
}
|
||||
|
||||
export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
|
||||
@ -132,10 +134,15 @@ export const generateToolEnvironment = ({
|
||||
}
|
||||
|
||||
case codeTools.qwenCode:
|
||||
env.OPENAI_API_KEY = apiKey
|
||||
env.OPENAI_BASE_URL = baseUrl
|
||||
env.OPENAI_MODEL = model.id
|
||||
break
|
||||
case codeTools.openaiCodex:
|
||||
env.OPENAI_API_KEY = apiKey
|
||||
env.OPENAI_BASE_URL = baseUrl
|
||||
env.OPENAI_MODEL = model.id
|
||||
env.OPENAI_MODEL_PROVIDER = modelProvider.id
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
import { FileType } from '@renderer/types'
|
||||
import { filterSupportedFiles } from '@renderer/utils/file'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import { FileType, KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { filterSupportedFiles, formatFileSize } from '@renderer/utils/file'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Paperclip } from 'lucide-react'
|
||||
import { FC, useCallback, useImperativeHandle, useState } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import { FileSearch, FileText, Paperclip, Upload } from 'lucide-react'
|
||||
import { Dispatch, FC, SetStateAction, useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface AttachmentButtonRef {
|
||||
openQuickPanel: () => void
|
||||
openFileSelectDialog: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@ -14,24 +19,17 @@ interface Props {
|
||||
couldAddImageFile: boolean
|
||||
extensions: string[]
|
||||
files: FileType[]
|
||||
setFiles: (files: FileType[]) => void
|
||||
ToolbarButton: any
|
||||
setFiles: Dispatch<SetStateAction<FileType[]>>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const AttachmentButton: FC<Props> = ({
|
||||
ref,
|
||||
couldAddImageFile,
|
||||
extensions,
|
||||
files,
|
||||
setFiles,
|
||||
ToolbarButton,
|
||||
disabled
|
||||
}) => {
|
||||
const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files, setFiles, disabled }) => {
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
const { bases: knowledgeBases } = useKnowledgeBases()
|
||||
const [selecting, setSelecting] = useState<boolean>(false)
|
||||
|
||||
const onSelectFile = useCallback(async () => {
|
||||
const openFileSelectDialog = useCallback(async () => {
|
||||
if (selecting) {
|
||||
return
|
||||
}
|
||||
@ -70,23 +68,88 @@ const AttachmentButton: FC<Props> = ({
|
||||
}
|
||||
}, [extensions, files, selecting, setFiles, t])
|
||||
|
||||
const openKnowledgeFileList = useCallback(
|
||||
(base: KnowledgeBase) => {
|
||||
quickPanel.open({
|
||||
title: base.name,
|
||||
list: base.items
|
||||
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
|
||||
.map((file) => {
|
||||
const fileContent = file.content as FileType
|
||||
return {
|
||||
label: fileContent.origin_name || fileContent.name,
|
||||
description:
|
||||
formatFileSize(fileContent.size) + ' · ' + dayjs(fileContent.created_at).format('YYYY-MM-DD HH:mm'),
|
||||
icon: <FileText />,
|
||||
isSelected: files.some((f) => f.path === fileContent.path),
|
||||
action: async ({ item }) => {
|
||||
item.isSelected = !item.isSelected
|
||||
if (fileContent.path) {
|
||||
setFiles((prevFiles) => {
|
||||
const fileExists = prevFiles.some((f) => f.path === fileContent.path)
|
||||
if (fileExists) {
|
||||
return prevFiles.filter((f) => f.path !== fileContent.path)
|
||||
} else {
|
||||
return fileContent ? [...prevFiles, fileContent] : prevFiles
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
symbol: QuickPanelReservedSymbol.File,
|
||||
multiple: true
|
||||
})
|
||||
},
|
||||
[files, quickPanel, setFiles]
|
||||
)
|
||||
|
||||
const items = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t('chat.input.upload.upload_from_local'),
|
||||
description: '',
|
||||
icon: <Upload />,
|
||||
action: () => openFileSelectDialog()
|
||||
},
|
||||
...knowledgeBases.map((base) => {
|
||||
const length = base.items?.filter(
|
||||
(item): item is KnowledgeItem => ['file', 'note'].includes(item.type) && typeof item.content !== 'string'
|
||||
).length
|
||||
return {
|
||||
label: base.name,
|
||||
description: `${length} ${t('files.count')}`,
|
||||
icon: <FileSearch />,
|
||||
disabled: length === 0,
|
||||
isMenu: true,
|
||||
action: () => openKnowledgeFileList(base)
|
||||
}
|
||||
})
|
||||
]
|
||||
}, [knowledgeBases, openFileSelectDialog, openKnowledgeFileList, t])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
onSelectFile()
|
||||
}, [onSelectFile])
|
||||
quickPanel.open({
|
||||
title: t('chat.input.upload.attachment'),
|
||||
list: items,
|
||||
symbol: QuickPanelReservedSymbol.File
|
||||
})
|
||||
}, [items, quickPanel, t])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
openQuickPanel,
|
||||
openFileSelectDialog
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={couldAddImageFile ? t('chat.input.upload.label') : t('chat.input.upload.document')}
|
||||
title={couldAddImageFile ? t('chat.input.upload.image_or_document') : t('chat.input.upload.document')}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={onSelectFile} disabled={disabled}>
|
||||
<Paperclip size={18} style={{ color: files.length ? 'var(--color-primary)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
<ActionIconButton onClick={openFileSelectDialog} active={files.length > 0} disabled={disabled}>
|
||||
<Paperclip size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { isGenerateImageModel } from '@renderer/config/models'
|
||||
import { Assistant, Model } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
@ -8,11 +9,10 @@ import { useTranslation } from 'react-i18next'
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
model: Model
|
||||
ToolbarButton: any
|
||||
onEnableGenerateImage: () => void
|
||||
}
|
||||
|
||||
const GenerateImageButton: FC<Props> = ({ model, ToolbarButton, assistant, onEnableGenerateImage }) => {
|
||||
const GenerateImageButton: FC<Props> = ({ model, assistant, onEnableGenerateImage }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@ -23,9 +23,12 @@ const GenerateImageButton: FC<Props> = ({ model, ToolbarButton, assistant, onEna
|
||||
}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" disabled={!isGenerateImageModel(model)} onClick={onEnableGenerateImage}>
|
||||
<Image size={18} color={assistant.enableGenerateImage ? 'var(--color-primary)' : 'var(--color-icon)'} />
|
||||
</ToolbarButton>
|
||||
<ActionIconButton
|
||||
onClick={onEnableGenerateImage}
|
||||
active={assistant.enableGenerateImage}
|
||||
disabled={!isGenerateImageModel(model)}>
|
||||
<Image size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,25 +1,23 @@
|
||||
import { HolderOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelReservedSymbol, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import {
|
||||
isAutoEnableImageGenerationModel,
|
||||
isGenerateImageModel,
|
||||
isGenerateImageModels,
|
||||
isMandatoryWebSearchModel,
|
||||
isSupportedReasoningEffortModel,
|
||||
isSupportedThinkingTokenModel,
|
||||
isVisionModel,
|
||||
isVisionModels,
|
||||
isWebSearchModel
|
||||
} from '@renderer/config/models'
|
||||
import db from '@renderer/databases'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import useTranslate from '@renderer/hooks/useTranslate'
|
||||
@ -27,7 +25,6 @@ import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import PasteService from '@renderer/services/PasteService'
|
||||
import { spanManagerService } from '@renderer/services/SpanManagerService'
|
||||
import { estimateTextTokens as estimateTxtTokens, estimateUserPromptUsage } from '@renderer/services/TokenService'
|
||||
@ -36,9 +33,9 @@ import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setSearching } from '@renderer/store/runtime'
|
||||
import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk'
|
||||
import { Assistant, FileType, FileTypes, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types'
|
||||
import { Assistant, FileType, KnowledgeBase, Model, Topic } from '@renderer/types'
|
||||
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
|
||||
import { classNames, delay, filterSupportedFiles, formatFileSize } from '@renderer/utils'
|
||||
import { classNames, delay, filterSupportedFiles } from '@renderer/utils'
|
||||
import { formatQuotedText } from '@renderer/utils/formats'
|
||||
import {
|
||||
getFilesFromDropEvent,
|
||||
@ -46,14 +43,12 @@ import {
|
||||
getTextFromDropEvent,
|
||||
isSendMessageKeyPressed
|
||||
} from '@renderer/utils/input'
|
||||
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import { Tooltip } from 'antd'
|
||||
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import dayjs from 'dayjs'
|
||||
import { debounce, isEmpty } from 'lodash'
|
||||
import { CirclePause, FileSearch, FileText, Upload } from 'lucide-react'
|
||||
import { CirclePause } from 'lucide-react'
|
||||
import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@ -114,7 +109,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const [textareaHeight, setTextareaHeight] = useState<number>()
|
||||
const startDragY = useRef<number>(0)
|
||||
const startHeight = useRef<number>(0)
|
||||
const { bases: knowledgeBases } = useKnowledgeBases()
|
||||
const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode)
|
||||
const isVisionAssistant = useMemo(() => isVisionModel(model), [model])
|
||||
const isGenerateImageAssistant = useMemo(() => isGenerateImageModel(model), [model])
|
||||
@ -134,11 +128,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
[mentionedModels, isGenerateImageAssistant]
|
||||
)
|
||||
|
||||
// 仅允许在不含图片文件时mention非视觉模型
|
||||
const couldMentionNotVisionModel = useMemo(() => {
|
||||
return !files.some((file) => file.type === FileTypes.IMAGE)
|
||||
}, [files])
|
||||
|
||||
// 允许在支持视觉或生成图片时添加图片文件
|
||||
const couldAddImageFile = useMemo(() => {
|
||||
return isVisionSupported || isGenerateImageSupported
|
||||
@ -185,8 +174,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
|
||||
const inputTokenCount = showInputEstimatedTokens ? tokenCount : 0
|
||||
|
||||
const newTopicShortcut = useShortcutDisplay('new_topic')
|
||||
const cleanTopicShortcut = useShortcutDisplay('clear_topic')
|
||||
const inputEmpty = isEmpty(text.trim()) && files.length === 0
|
||||
|
||||
_text = text
|
||||
@ -279,72 +266,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
}, [isTranslating, text, getLanguageByLangcode, targetLanguage, setTimeoutTimer, resizeTextArea])
|
||||
|
||||
const openKnowledgeFileList = useCallback(
|
||||
(base: KnowledgeBase) => {
|
||||
quickPanel.open({
|
||||
title: base.name,
|
||||
list: base.items
|
||||
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
|
||||
.map((file) => {
|
||||
const fileContent = file.content as FileType
|
||||
return {
|
||||
label: fileContent.origin_name || fileContent.name,
|
||||
description:
|
||||
formatFileSize(fileContent.size) + ' · ' + dayjs(fileContent.created_at).format('YYYY-MM-DD HH:mm'),
|
||||
icon: <FileText />,
|
||||
isSelected: files.some((f) => f.path === fileContent.path),
|
||||
action: async ({ item }) => {
|
||||
item.isSelected = !item.isSelected
|
||||
if (fileContent.path) {
|
||||
setFiles((prevFiles) => {
|
||||
const fileExists = prevFiles.some((f) => f.path === fileContent.path)
|
||||
if (fileExists) {
|
||||
return prevFiles.filter((f) => f.path !== fileContent.path)
|
||||
} else {
|
||||
return fileContent ? [...prevFiles, fileContent] : prevFiles
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
symbol: 'file',
|
||||
multiple: true
|
||||
})
|
||||
},
|
||||
[files, quickPanel]
|
||||
)
|
||||
|
||||
const openSelectFileMenu = useCallback(() => {
|
||||
quickPanel.open({
|
||||
title: t('chat.input.upload.label'),
|
||||
list: [
|
||||
{
|
||||
label: t('chat.input.upload.upload_from_local'),
|
||||
description: '',
|
||||
icon: <Upload />,
|
||||
action: () => {
|
||||
inputbarToolsRef.current?.openAttachmentQuickPanel()
|
||||
}
|
||||
},
|
||||
...knowledgeBases.map((base) => {
|
||||
const length = base.items?.filter(
|
||||
(item): item is KnowledgeItem => ['file', 'note'].includes(item.type) && typeof item.content !== 'string'
|
||||
).length
|
||||
return {
|
||||
label: base.name,
|
||||
description: `${length} ${t('files.count')}`,
|
||||
icon: <FileSearch />,
|
||||
disabled: length === 0,
|
||||
isMenu: true,
|
||||
action: () => openKnowledgeFileList(base)
|
||||
}
|
||||
})
|
||||
],
|
||||
symbol: 'file'
|
||||
})
|
||||
}, [knowledgeBases, openKnowledgeFileList, quickPanel, t, inputbarToolsRef])
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// 按下Tab键,自动选中${xxx}
|
||||
if (event.key === 'Tab' && inputFocus) {
|
||||
@ -406,37 +327,40 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
//other keys should be ignored
|
||||
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
|
||||
if (isEnterPressed) {
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
|
||||
// 1) 优先判断是否为“发送”(当前仅支持纯 Enter 发送;其余 Enter 组合键均换行)
|
||||
if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
} else {
|
||||
//shift+enter's default behavior is to add a new line, ignore it
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
const start = textArea.selectionStart
|
||||
const end = textArea.selectionEnd
|
||||
const text = textArea.value
|
||||
const newText = text.substring(0, start) + '\n' + text.substring(end)
|
||||
// 2) 不再基于 quickPanel.isVisible 主动拦截。
|
||||
// 纯 Enter 的处理权交由 QuickPanel 的全局捕获(其只在纯 Enter 时拦截),
|
||||
// 其它带修饰键的 Enter 则由输入框处理为换行。
|
||||
|
||||
// update text by setState, not directly modify textarea.value
|
||||
setText(newText)
|
||||
if (event.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
// set cursor position in the next render cycle
|
||||
setTimeoutTimer(
|
||||
'handleKeyDown',
|
||||
() => {
|
||||
textArea.selectionStart = textArea.selectionEnd = start + 1
|
||||
onInput() // trigger resizeTextArea
|
||||
},
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
event.preventDefault()
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
const start = textArea.selectionStart
|
||||
const end = textArea.selectionEnd
|
||||
const text = textArea.value
|
||||
const newText = text.substring(0, start) + '\n' + text.substring(end)
|
||||
|
||||
// update text by setState, not directly modify textarea.value
|
||||
setText(newText)
|
||||
|
||||
// set cursor position in the next render cycle
|
||||
setTimeoutTimer(
|
||||
'handleKeyDown',
|
||||
() => {
|
||||
textArea.selectionStart = textArea.selectionEnd = start + 1
|
||||
onInput() // trigger resizeTextArea
|
||||
},
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -509,35 +433,31 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const lastSymbol = newText[cursorPosition - 1]
|
||||
|
||||
// 触发符号为 '/':若当前未打开或符号不同,则切换/打开
|
||||
if (enableQuickPanelTriggers && lastSymbol === '/') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol !== '/') {
|
||||
if (enableQuickPanelTriggers && lastSymbol === QuickPanelReservedSymbol.Root) {
|
||||
if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.Root) {
|
||||
quickPanel.close('switch-symbol')
|
||||
}
|
||||
if (!quickPanel.isVisible || quickPanel.symbol !== '/') {
|
||||
if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.Root) {
|
||||
const quickPanelMenu =
|
||||
inputbarToolsRef.current?.getQuickPanelMenu({
|
||||
t,
|
||||
files,
|
||||
couldAddImageFile,
|
||||
text: newText,
|
||||
openSelectFileMenu,
|
||||
translate
|
||||
}) || []
|
||||
|
||||
quickPanel.open({
|
||||
title: t('settings.quickPanel.title'),
|
||||
list: quickPanelMenu,
|
||||
symbol: '/'
|
||||
symbol: QuickPanelReservedSymbol.Root
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 触发符号为 '@':若当前未打开或符号不同,则切换/打开
|
||||
if (enableQuickPanelTriggers && lastSymbol === '@') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol !== '@') {
|
||||
if (enableQuickPanelTriggers && lastSymbol === QuickPanelReservedSymbol.MentionModels) {
|
||||
if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) {
|
||||
quickPanel.close('switch-symbol')
|
||||
}
|
||||
if (!quickPanel.isVisible || quickPanel.symbol !== '@') {
|
||||
if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) {
|
||||
inputbarToolsRef.current?.openMentionModelsPanel({
|
||||
type: 'input',
|
||||
position: cursorPosition - 1,
|
||||
@ -546,7 +466,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
}
|
||||
},
|
||||
[enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate]
|
||||
[enableQuickPanelTriggers, quickPanel, t, translate]
|
||||
)
|
||||
|
||||
const onPaste = useCallback(
|
||||
@ -762,11 +682,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
setSelectedKnowledgeBases(showKnowledgeIcon ? (assistant.knowledge_bases ?? []) : [])
|
||||
}, [assistant.id, assistant.knowledge_bases, showKnowledgeIcon])
|
||||
|
||||
const handleKnowledgeBaseSelect = (bases?: KnowledgeBase[]) => {
|
||||
updateAssistant({ ...assistant, knowledge_bases: bases })
|
||||
setSelectedKnowledgeBases(bases ?? [])
|
||||
}
|
||||
|
||||
const handleRemoveModel = (model: Model) => {
|
||||
setMentionedModels(mentionedModels.filter((m) => m.id !== model.id))
|
||||
}
|
||||
@ -780,10 +695,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
setSelectedKnowledgeBases(newKnowledgeBases ?? [])
|
||||
}
|
||||
|
||||
const onEnableGenerateImage = () => {
|
||||
updateAssistant({ ...assistant, enableGenerateImage: !assistant.enableGenerateImage })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWebSearchModel(model) && assistant.enableWebSearch) {
|
||||
updateAssistant({ ...assistant, enableWebSearch: false })
|
||||
@ -803,24 +714,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
}, [assistant, model, updateAssistant])
|
||||
|
||||
const onMentionModel = useCallback(
|
||||
(model: Model) => {
|
||||
// 我想应该没有模型是只支持视觉而不支持文本的?
|
||||
if (isVisionModel(model) || couldMentionNotVisionModel) {
|
||||
setMentionedModels((prev) => {
|
||||
const modelId = getModelUniqId(model)
|
||||
const exists = prev.some((m) => getModelUniqId(m) === modelId)
|
||||
return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model]
|
||||
})
|
||||
} else {
|
||||
logger.error('Cannot add non-vision model when images are uploaded')
|
||||
}
|
||||
},
|
||||
[couldMentionNotVisionModel]
|
||||
)
|
||||
|
||||
const onClearMentionModels = useCallback(() => setMentionedModels([]), [setMentionedModels])
|
||||
|
||||
const onToggleExpanded = () => {
|
||||
const currentlyExpanded = expanded || !!textareaHeight
|
||||
const shouldExpand = !currentlyExpanded
|
||||
@ -845,8 +738,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
|
||||
const isExpanded = expanded || !!textareaHeight
|
||||
const showThinkingButton = isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)
|
||||
const showMcpTools = isSupportedToolUse(assistant) || isPromptToolUse(assistant)
|
||||
|
||||
if (isMultiSelectMode) {
|
||||
return null
|
||||
@ -918,47 +809,38 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
<Toolbar>
|
||||
<InputbarTools
|
||||
ref={inputbarToolsRef}
|
||||
assistant={assistant}
|
||||
assistantId={assistant.id}
|
||||
model={model}
|
||||
files={files}
|
||||
extensions={supportedExts}
|
||||
setFiles={setFiles}
|
||||
showThinkingButton={showThinkingButton}
|
||||
showKnowledgeIcon={showKnowledgeIcon && showMcpTools}
|
||||
showMcpTools={showMcpTools}
|
||||
selectedKnowledgeBases={selectedKnowledgeBases}
|
||||
handleKnowledgeBaseSelect={handleKnowledgeBaseSelect}
|
||||
setText={setText}
|
||||
resizeTextArea={resizeTextArea}
|
||||
mentionModels={mentionedModels}
|
||||
onMentionModel={onMentionModel}
|
||||
onClearMentionModels={onClearMentionModels}
|
||||
couldMentionNotVisionModel={couldMentionNotVisionModel}
|
||||
selectedKnowledgeBases={selectedKnowledgeBases}
|
||||
setSelectedKnowledgeBases={setSelectedKnowledgeBases}
|
||||
mentionedModels={mentionedModels}
|
||||
setMentionedModels={setMentionedModels}
|
||||
couldAddImageFile={couldAddImageFile}
|
||||
onEnableGenerateImage={onEnableGenerateImage}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
addNewTopic={addNewTopic}
|
||||
clearTopic={clearTopic}
|
||||
onNewContext={onNewContext}
|
||||
newTopicShortcut={newTopicShortcut}
|
||||
cleanTopicShortcut={cleanTopicShortcut}
|
||||
/>
|
||||
<ToolbarMenu>
|
||||
<TokenCount
|
||||
estimateTokenCount={estimateTokenCount}
|
||||
inputTokenCount={inputTokenCount}
|
||||
contextCount={contextCount}
|
||||
ToolbarButton={ToolbarButton}
|
||||
onClick={onNewContext}
|
||||
/>
|
||||
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
|
||||
<SendMessageButton sendMessage={sendMessage} disabled={inputEmpty} />
|
||||
{loading && (
|
||||
<Tooltip placement="top" title={t('chat.input.pause')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2 }}>
|
||||
<ActionIconButton onClick={onPause} style={{ marginRight: -2 }}>
|
||||
<CirclePause size={20} color="var(--color-error)" />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ToolbarMenu>
|
||||
@ -1073,45 +955,4 @@ const ToolbarMenu = styled.div`
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
export const ToolbarButton = styled(Button)`
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 16px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-icon);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
&.anticon,
|
||||
&.iconfont {
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-icon);
|
||||
}
|
||||
.icon-a-addchat {
|
||||
font-size: 18px;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
.anticon,
|
||||
.iconfont {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--color-primary) !important;
|
||||
.anticon,
|
||||
.iconfont,
|
||||
.chevron-icon {
|
||||
color: var(--color-white-soft);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default Inputbar
|
||||
|
||||
@ -1,12 +1,26 @@
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
|
||||
import { loggerService } from '@logger'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
||||
import { isGeminiModel, isGenerateImageModel, isMandatoryWebSearchModel } from '@renderer/config/models'
|
||||
import {
|
||||
isGeminiModel,
|
||||
isGenerateImageModel,
|
||||
isMandatoryWebSearchModel,
|
||||
isSupportedReasoningEffortModel,
|
||||
isSupportedThinkingTokenModel,
|
||||
isVisionModel
|
||||
} from '@renderer/config/models'
|
||||
import { isSupportUrlContextProvider } from '@renderer/config/providers'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
|
||||
import { Assistant, FileType, KnowledgeBase, Model } from '@renderer/types'
|
||||
import { FileType, FileTypes, KnowledgeBase, Model } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
|
||||
import { Divider, Dropdown, Tooltip } from 'antd'
|
||||
import { ItemType } from 'antd/es/menu/interface'
|
||||
import {
|
||||
@ -32,7 +46,6 @@ import styled from 'styled-components'
|
||||
|
||||
import AttachmentButton, { AttachmentButtonRef } from './AttachmentButton'
|
||||
import GenerateImageButton from './GenerateImageButton'
|
||||
import { ToolbarButton } from './Inputbar'
|
||||
import KnowledgeBaseButton, { KnowledgeBaseButtonRef } from './KnowledgeBaseButton'
|
||||
import MCPToolsButton, { MCPToolsButtonRef } from './MCPToolsButton'
|
||||
import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButton'
|
||||
@ -42,47 +55,33 @@ import ThinkingButton, { ThinkingButtonRef } from './ThinkingButton'
|
||||
import UrlContextButton, { UrlContextButtonRef } from './UrlContextbutton'
|
||||
import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton'
|
||||
|
||||
const logger = loggerService.withContext('InputbarTools')
|
||||
|
||||
export interface InputbarToolsRef {
|
||||
getQuickPanelMenu: (params: {
|
||||
t: (key: string, options?: any) => string
|
||||
files: FileType[]
|
||||
couldAddImageFile: boolean
|
||||
text: string
|
||||
openSelectFileMenu: () => void
|
||||
translate: () => void
|
||||
}) => QuickPanelListItem[]
|
||||
getQuickPanelMenu: (params: { text: string; translate: () => void }) => QuickPanelListItem[]
|
||||
openMentionModelsPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void
|
||||
openAttachmentQuickPanel: () => void
|
||||
}
|
||||
|
||||
export interface InputbarToolsProps {
|
||||
assistant: Assistant
|
||||
assistantId: string
|
||||
model: Model
|
||||
files: FileType[]
|
||||
setFiles: (files: FileType[]) => void
|
||||
setFiles: Dispatch<SetStateAction<FileType[]>>
|
||||
extensions: string[]
|
||||
showThinkingButton: boolean
|
||||
showKnowledgeIcon: boolean
|
||||
showMcpTools: boolean
|
||||
selectedKnowledgeBases: KnowledgeBase[]
|
||||
handleKnowledgeBaseSelect: (bases?: KnowledgeBase[]) => void
|
||||
setText: Dispatch<SetStateAction<string>>
|
||||
resizeTextArea: () => void
|
||||
mentionModels: Model[]
|
||||
onMentionModel: (model: Model) => void
|
||||
onClearMentionModels: () => void
|
||||
couldMentionNotVisionModel: boolean
|
||||
selectedKnowledgeBases: KnowledgeBase[]
|
||||
setSelectedKnowledgeBases: Dispatch<SetStateAction<KnowledgeBase[]>>
|
||||
mentionedModels: Model[]
|
||||
setMentionedModels: Dispatch<SetStateAction<Model[]>>
|
||||
couldAddImageFile: boolean
|
||||
onEnableGenerateImage: () => void
|
||||
isExpanded: boolean
|
||||
onToggleExpanded: () => void
|
||||
|
||||
addNewTopic: () => void
|
||||
clearTopic: () => void
|
||||
onNewContext: () => void
|
||||
|
||||
newTopicShortcut: string
|
||||
cleanTopicShortcut: string
|
||||
}
|
||||
|
||||
interface ToolButtonConfig {
|
||||
@ -100,34 +99,27 @@ const DraggablePortal = ({ children, isDragging }) => {
|
||||
|
||||
const InputbarTools = ({
|
||||
ref,
|
||||
assistant,
|
||||
assistantId,
|
||||
model,
|
||||
files,
|
||||
setFiles,
|
||||
showThinkingButton,
|
||||
showKnowledgeIcon,
|
||||
showMcpTools,
|
||||
selectedKnowledgeBases,
|
||||
handleKnowledgeBaseSelect,
|
||||
setText,
|
||||
resizeTextArea,
|
||||
mentionModels,
|
||||
onMentionModel,
|
||||
onClearMentionModels,
|
||||
couldMentionNotVisionModel,
|
||||
selectedKnowledgeBases,
|
||||
setSelectedKnowledgeBases,
|
||||
mentionedModels,
|
||||
setMentionedModels,
|
||||
couldAddImageFile,
|
||||
onEnableGenerateImage,
|
||||
isExpanded: isExpended,
|
||||
onToggleExpanded: onToggleExpended,
|
||||
addNewTopic,
|
||||
clearTopic,
|
||||
onNewContext,
|
||||
newTopicShortcut,
|
||||
cleanTopicShortcut,
|
||||
extensions
|
||||
}: InputbarToolsProps & { ref?: React.RefObject<InputbarToolsRef | null> }) => {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||
|
||||
const quickPhrasesButtonRef = useRef<QuickPhrasesButtonRef>(null)
|
||||
const mentionModelsButtonRef = useRef<MentionModelsButtonRef>(null)
|
||||
@ -143,6 +135,54 @@ const InputbarTools = ({
|
||||
|
||||
const [targetTool, setTargetTool] = useState<ToolButtonConfig | null>(null)
|
||||
|
||||
const showThinkingButton = useMemo(
|
||||
() => isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model),
|
||||
[model]
|
||||
)
|
||||
|
||||
const showMcpServerButton = useMemo(() => isSupportedToolUse(assistant) || isPromptToolUse(assistant), [assistant])
|
||||
|
||||
const knowledgeSidebarEnabled = useSidebarIconShow('knowledge')
|
||||
const showKnowledgeBaseButton = knowledgeSidebarEnabled && showMcpServerButton
|
||||
|
||||
const handleKnowledgeBaseSelect = useCallback(
|
||||
(bases?: KnowledgeBase[]) => {
|
||||
updateAssistant({ knowledge_bases: bases })
|
||||
setSelectedKnowledgeBases(bases ?? [])
|
||||
},
|
||||
[setSelectedKnowledgeBases, updateAssistant]
|
||||
)
|
||||
|
||||
// 仅允许在不含图片文件时mention非视觉模型
|
||||
const couldMentionNotVisionModel = useMemo(() => {
|
||||
return !files.some((file) => file.type === FileTypes.IMAGE)
|
||||
}, [files])
|
||||
|
||||
const onMentionModel = useCallback(
|
||||
(model: Model) => {
|
||||
// 我想应该没有模型是只支持视觉而不支持文本的?
|
||||
if (isVisionModel(model) || couldMentionNotVisionModel) {
|
||||
setMentionedModels((prev) => {
|
||||
const modelId = getModelUniqId(model)
|
||||
const exists = prev.some((m) => getModelUniqId(m) === modelId)
|
||||
return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model]
|
||||
})
|
||||
} else {
|
||||
logger.error('Cannot add non-vision model when images are uploaded')
|
||||
}
|
||||
},
|
||||
[couldMentionNotVisionModel, setMentionedModels]
|
||||
)
|
||||
|
||||
const onClearMentionModels = useCallback(() => setMentionedModels([]), [setMentionedModels])
|
||||
|
||||
const onEnableGenerateImage = useCallback(() => {
|
||||
updateAssistant({ enableGenerateImage: !assistant.enableGenerateImage })
|
||||
}, [assistant.enableGenerateImage, updateAssistant])
|
||||
|
||||
const newTopicShortcut = useShortcutDisplay('new_topic')
|
||||
const clearTopicShortcut = useShortcutDisplay('clear_topic')
|
||||
|
||||
const toggleToolVisibility = useCallback(
|
||||
(toolKey: string, isVisible: boolean | undefined) => {
|
||||
const newToolOrder = {
|
||||
@ -164,15 +204,8 @@ const InputbarTools = ({
|
||||
[dispatch, toolOrder.hidden, toolOrder.visible]
|
||||
)
|
||||
|
||||
const getQuickPanelMenuImpl = (params: {
|
||||
t: (key: string, options?: any) => string
|
||||
files: FileType[]
|
||||
couldAddImageFile: boolean
|
||||
text: string
|
||||
openSelectFileMenu: () => void
|
||||
translate: () => void
|
||||
}): QuickPanelListItem[] => {
|
||||
const { t, files, couldAddImageFile, text, openSelectFileMenu, translate } = params
|
||||
const getQuickPanelMenuImpl = (params: { text: string; translate: () => void }): QuickPanelListItem[] => {
|
||||
const { text, translate } = params
|
||||
|
||||
return [
|
||||
{
|
||||
@ -249,11 +282,13 @@ const InputbarTools = ({
|
||||
}
|
||||
},
|
||||
{
|
||||
label: couldAddImageFile ? t('chat.input.upload.label') : t('chat.input.upload.document'),
|
||||
label: couldAddImageFile ? t('chat.input.upload.attachment') : t('chat.input.upload.document'),
|
||||
description: '',
|
||||
icon: <Paperclip />,
|
||||
isMenu: true,
|
||||
action: openSelectFileMenu
|
||||
action: () => {
|
||||
attachmentButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('translate.title'),
|
||||
@ -313,15 +348,15 @@ const InputbarTools = ({
|
||||
title={t('chat.input.new_topic', { Command: newTopicShortcut })}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={addNewTopic}>
|
||||
<ActionIconButton onClick={addNewTopic}>
|
||||
<MessageSquareDiff size={19} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'attachment',
|
||||
label: t('chat.input.upload.label'),
|
||||
label: t('chat.input.upload.image_or_document'),
|
||||
component: (
|
||||
<AttachmentButton
|
||||
ref={attachmentButtonRef}
|
||||
@ -329,28 +364,25 @@ const InputbarTools = ({
|
||||
extensions={extensions}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
ToolbarButton={ToolbarButton}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'thinking',
|
||||
label: t('chat.input.thinking.label'),
|
||||
component: (
|
||||
<ThinkingButton ref={thinkingButtonRef} model={model} assistant={assistant} ToolbarButton={ToolbarButton} />
|
||||
),
|
||||
component: <ThinkingButton ref={thinkingButtonRef} model={model} assistantId={assistant.id} />,
|
||||
condition: showThinkingButton
|
||||
},
|
||||
{
|
||||
key: 'web_search',
|
||||
label: t('chat.input.web_search.label'),
|
||||
component: <WebSearchButton ref={webSearchButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />,
|
||||
component: <WebSearchButton ref={webSearchButtonRef} assistantId={assistant.id} />,
|
||||
condition: !isMandatoryWebSearchModel(model)
|
||||
},
|
||||
{
|
||||
key: 'url_context',
|
||||
label: t('chat.input.url_context'),
|
||||
component: <UrlContextButton ref={urlContextButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />,
|
||||
component: <UrlContextButton ref={urlContextButtonRef} assistantId={assistant.id} />,
|
||||
condition: isGeminiModel(model) && isSupportUrlContextProvider(getProviderByModel(model))
|
||||
},
|
||||
{
|
||||
@ -361,36 +393,29 @@ const InputbarTools = ({
|
||||
ref={knowledgeBaseButtonRef}
|
||||
selectedBases={selectedKnowledgeBases}
|
||||
onSelect={handleKnowledgeBaseSelect}
|
||||
ToolbarButton={ToolbarButton}
|
||||
disabled={files.length > 0}
|
||||
/>
|
||||
),
|
||||
condition: showKnowledgeIcon
|
||||
condition: showKnowledgeBaseButton
|
||||
},
|
||||
{
|
||||
key: 'mcp_tools',
|
||||
label: t('settings.mcp.title'),
|
||||
component: (
|
||||
<MCPToolsButton
|
||||
assistant={assistant}
|
||||
assistantId={assistant.id}
|
||||
ref={mcpToolsButtonRef}
|
||||
ToolbarButton={ToolbarButton}
|
||||
setInputValue={setText}
|
||||
resizeTextArea={resizeTextArea}
|
||||
/>
|
||||
),
|
||||
condition: showMcpTools
|
||||
condition: showMcpServerButton
|
||||
},
|
||||
{
|
||||
key: 'generate_image',
|
||||
label: t('chat.input.generate_image'),
|
||||
component: (
|
||||
<GenerateImageButton
|
||||
model={model}
|
||||
assistant={assistant}
|
||||
onEnableGenerateImage={onEnableGenerateImage}
|
||||
ToolbarButton={ToolbarButton}
|
||||
/>
|
||||
<GenerateImageButton model={model} assistant={assistant} onEnableGenerateImage={onEnableGenerateImage} />
|
||||
),
|
||||
condition: isGenerateImageModel(model)
|
||||
},
|
||||
@ -400,10 +425,9 @@ const InputbarTools = ({
|
||||
component: (
|
||||
<MentionModelsButton
|
||||
ref={mentionModelsButtonRef}
|
||||
mentionedModels={mentionModels}
|
||||
mentionedModels={mentionedModels}
|
||||
onMentionModel={onMentionModel}
|
||||
onClearMentionModels={onClearMentionModels}
|
||||
ToolbarButton={ToolbarButton}
|
||||
couldMentionNotVisionModel={couldMentionNotVisionModel}
|
||||
files={files}
|
||||
setText={setText}
|
||||
@ -418,8 +442,7 @@ const InputbarTools = ({
|
||||
ref={quickPhrasesButtonRef}
|
||||
setInputValue={setText}
|
||||
resizeTextArea={resizeTextArea}
|
||||
ToolbarButton={ToolbarButton}
|
||||
assistantObj={assistant}
|
||||
assistantId={assistant.id}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@ -429,12 +452,12 @@ const InputbarTools = ({
|
||||
component: (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={t('chat.input.clear.label', { Command: cleanTopicShortcut })}
|
||||
title={t('chat.input.clear.label', { Command: clearTopicShortcut })}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={clearTopic}>
|
||||
<ActionIconButton onClick={clearTopic}>
|
||||
<PaintbrushVertical size={18} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
@ -447,22 +470,22 @@ const InputbarTools = ({
|
||||
title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={onToggleExpended}>
|
||||
<ActionIconButton onClick={onToggleExpended}>
|
||||
{isExpended ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'new_context',
|
||||
label: t('chat.input.new.context', { Command: '' }),
|
||||
component: <NewContextButton onNewContext={onNewContext} ToolbarButton={ToolbarButton} />
|
||||
component: <NewContextButton onNewContext={onNewContext} />
|
||||
}
|
||||
]
|
||||
}, [
|
||||
addNewTopic,
|
||||
assistant,
|
||||
cleanTopicShortcut,
|
||||
clearTopicShortcut,
|
||||
clearTopic,
|
||||
couldAddImageFile,
|
||||
couldMentionNotVisionModel,
|
||||
@ -470,7 +493,7 @@ const InputbarTools = ({
|
||||
files,
|
||||
handleKnowledgeBaseSelect,
|
||||
isExpended,
|
||||
mentionModels,
|
||||
mentionedModels,
|
||||
model,
|
||||
newTopicShortcut,
|
||||
onClearMentionModels,
|
||||
@ -482,8 +505,8 @@ const InputbarTools = ({
|
||||
selectedKnowledgeBases,
|
||||
setFiles,
|
||||
setText,
|
||||
showKnowledgeIcon,
|
||||
showMcpTools,
|
||||
showKnowledgeBaseButton,
|
||||
showMcpServerButton,
|
||||
showThinkingButton,
|
||||
t
|
||||
])
|
||||
@ -628,14 +651,14 @@ const InputbarTools = ({
|
||||
placement="top"
|
||||
title={isCollapse ? t('chat.input.tools.expand') : t('chat.input.tools.collapse')}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={() => dispatch(setIsCollapsed(!isCollapse))}>
|
||||
<ActionIconButton onClick={() => dispatch(setIsCollapsed(!isCollapse))}>
|
||||
<CircleChevronRight
|
||||
size={18}
|
||||
style={{
|
||||
transform: isCollapse ? 'scaleX(1)' : 'scaleX(-1)'
|
||||
}}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ToolsContainer>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { KnowledgeBase } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
@ -16,10 +17,9 @@ interface Props {
|
||||
selectedBases?: KnowledgeBase[]
|
||||
onSelect: (bases: KnowledgeBase[]) => void
|
||||
disabled?: boolean
|
||||
ToolbarButton: any
|
||||
}
|
||||
|
||||
const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled, ToolbarButton }) => {
|
||||
const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled }) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const quickPanel = useQuickPanel()
|
||||
@ -77,7 +77,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
||||
quickPanel.open({
|
||||
title: t('chat.input.knowledge_base'),
|
||||
list: baseItems,
|
||||
symbol: '#',
|
||||
symbol: QuickPanelReservedSymbol.KnowledgeBase,
|
||||
multiple: true,
|
||||
afterAction({ item }) {
|
||||
item.isSelected = !item.isSelected
|
||||
@ -86,7 +86,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
||||
}, [baseItems, quickPanel, t])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '#') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
@ -95,7 +95,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
||||
|
||||
// 监听 selectedBases 变化,动态更新已打开的 QuickPanel 列表状态
|
||||
useEffect(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '#') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
||||
// 直接使用重新计算的 baseItems,因为它已经包含了最新的 isSelected 状态
|
||||
quickPanel.updateList(baseItems)
|
||||
}
|
||||
@ -107,12 +107,12 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('chat.input.knowledge_base')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel} disabled={disabled}>
|
||||
<FileSearch
|
||||
size={18}
|
||||
color={selectedBases && selectedBases.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)'}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
<ActionIconButton
|
||||
onClick={handleOpenQuickPanel}
|
||||
active={selectedBases && selectedBases.length > 0}
|
||||
disabled={disabled}>
|
||||
<FileSearch size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { isGeminiModel } from '@renderer/config/models'
|
||||
import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@renderer/config/providers'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
@ -6,7 +7,7 @@ import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { EventEmitter } from '@renderer/services/EventService'
|
||||
import { Assistant, MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
|
||||
import { MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
|
||||
import { isToolUseModeFunction } from '@renderer/utils/assistant'
|
||||
import { Form, Input, Tooltip } from 'antd'
|
||||
import { CircleX, Hammer, Plus } from 'lucide-react'
|
||||
@ -21,11 +22,10 @@ export interface MCPToolsButtonRef {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
assistantId: string
|
||||
ref?: React.RefObject<MCPToolsButtonRef | null>
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
||||
resizeTextArea: () => void
|
||||
ToolbarButton: any
|
||||
}
|
||||
|
||||
// 添加类型定义
|
||||
@ -113,14 +113,14 @@ const extractPromptContent = (response: any): string | null => {
|
||||
return null
|
||||
}
|
||||
|
||||
const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, ToolbarButton, ...props }) => {
|
||||
const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assistantId }) => {
|
||||
const { activedMcpServers } = useMCPServers()
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const { updateAssistant, assistant } = useAssistant(props.assistant.id)
|
||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||
const model = assistant.model
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
@ -228,7 +228,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
quickPanel.open({
|
||||
title: t('settings.mcp.title'),
|
||||
list: menuItems,
|
||||
symbol: 'mcp',
|
||||
symbol: QuickPanelReservedSymbol.Mcp,
|
||||
multiple: true,
|
||||
afterAction({ item }) {
|
||||
item.isSelected = !item.isSelected
|
||||
@ -318,7 +318,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
})
|
||||
|
||||
await handlePromptResponse(response)
|
||||
} catch (error: Error | any) {
|
||||
} catch (error: any) {
|
||||
if (error.message !== 'cancelled') {
|
||||
window.modal.error({
|
||||
title: t('common.error'),
|
||||
@ -335,7 +335,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
name: prompt.name
|
||||
})
|
||||
await handlePromptResponse(response)
|
||||
} catch (error: Error | any) {
|
||||
} catch (error: any) {
|
||||
window.modal.error({
|
||||
title: t('common.error'),
|
||||
content: error.message || t('settings.mcp.prompts.genericError')
|
||||
@ -377,7 +377,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
quickPanel.open({
|
||||
title: t('settings.mcp.title'),
|
||||
list: prompts,
|
||||
symbol: 'mcp-prompt',
|
||||
symbol: QuickPanelReservedSymbol.McpPrompt,
|
||||
multiple: true
|
||||
})
|
||||
}, [promptList, quickPanel, t])
|
||||
@ -416,7 +416,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
} else {
|
||||
processResourceContent(response as ResourceData)
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
} catch (error: any) {
|
||||
window.modal.error({
|
||||
title: t('common.error'),
|
||||
content: error.message || t('settings.mcp.resources.genericError')
|
||||
@ -465,13 +465,13 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
quickPanel.open({
|
||||
title: t('settings.mcp.title'),
|
||||
list: resourcesList,
|
||||
symbol: 'mcp-resource',
|
||||
symbol: QuickPanelReservedSymbol.McpResource,
|
||||
multiple: true
|
||||
})
|
||||
}, [resourcesList, quickPanel, t])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === 'mcp') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Mcp) {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
@ -486,12 +486,9 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
<Hammer
|
||||
size={18}
|
||||
color={assistant.mcpServers && assistant.mcpServers.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)'}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
<ActionIconButton onClick={handleOpenQuickPanel} active={assistant.mcpServers && assistant.mcpServers.length > 0}>
|
||||
<Hammer size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
|
||||
import { useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { QuickPanelListItem } from '@renderer/components/QuickPanel/types'
|
||||
import { type QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { getModelLogo, isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models'
|
||||
import db from '@renderer/databases'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
@ -27,7 +27,6 @@ interface Props {
|
||||
onClearMentionModels: () => void
|
||||
couldMentionNotVisionModel: boolean
|
||||
files: FileType[]
|
||||
ToolbarButton: any
|
||||
setText: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
|
||||
@ -38,7 +37,6 @@ const MentionModelsButton: FC<Props> = ({
|
||||
onClearMentionModels,
|
||||
couldMentionNotVisionModel,
|
||||
files,
|
||||
ToolbarButton,
|
||||
setText
|
||||
}) => {
|
||||
const { providers } = useProviders()
|
||||
@ -242,7 +240,7 @@ const MentionModelsButton: FC<Props> = ({
|
||||
quickPanel.open({
|
||||
title: t('agents.edit.model.select.title'),
|
||||
list: modelItems,
|
||||
symbol: '@',
|
||||
symbol: QuickPanelReservedSymbol.MentionModels,
|
||||
multiple: true,
|
||||
triggerInfo: triggerInfo || { type: 'button' },
|
||||
afterAction({ item }) {
|
||||
@ -274,7 +272,7 @@ const MentionModelsButton: FC<Props> = ({
|
||||
)
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '@') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel({ type: 'button' })
|
||||
@ -286,7 +284,7 @@ const MentionModelsButton: FC<Props> = ({
|
||||
useEffect(() => {
|
||||
// 检查files是否变化
|
||||
if (filesRef.current !== files) {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '@') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
|
||||
quickPanel.close()
|
||||
}
|
||||
filesRef.current = files
|
||||
@ -295,7 +293,7 @@ const MentionModelsButton: FC<Props> = ({
|
||||
|
||||
// 监听 mentionedModels 变化,动态更新已打开的 QuickPanel 列表状态
|
||||
useEffect(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '@') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
|
||||
// 直接使用重新计算的 modelItems,因为它已经包含了最新的 isSelected 状态
|
||||
quickPanel.updateList(modelItems)
|
||||
}
|
||||
@ -307,9 +305,9 @@ const MentionModelsButton: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('agents.edit.model.select.title')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
<AtSign size={18} color={mentionedModels.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)'} />
|
||||
</ToolbarButton>
|
||||
<ActionIconButton onClick={handleOpenQuickPanel} active={mentionedModels.length > 0}>
|
||||
<AtSign size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Eraser } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
onNewContext: () => void
|
||||
ToolbarButton: any
|
||||
}
|
||||
|
||||
const NewContextButton: FC<Props> = ({ onNewContext, ToolbarButton }) => {
|
||||
const NewContextButton: FC<Props> = ({ onNewContext }) => {
|
||||
const newContextShortcut = useShortcutDisplay('toggle_new_context')
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -21,9 +20,9 @@ const NewContextButton: FC<Props> = ({ onNewContext, ToolbarButton }) => {
|
||||
title={t('chat.input.new.context', { Command: newContextShortcut })}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={onNewContext}>
|
||||
<ActionIconButton onClick={onNewContext}>
|
||||
<Eraser size={18} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import {
|
||||
type QuickPanelListItem,
|
||||
type QuickPanelOpenOptions,
|
||||
QuickPanelReservedSymbol
|
||||
} from '@renderer/components/QuickPanel'
|
||||
import { useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { QuickPanelListItem, QuickPanelOpenOptions } from '@renderer/components/QuickPanel/types'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import QuickPhraseService from '@renderer/services/QuickPhraseService'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { QuickPhrase } from '@renderer/types'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { Input, Modal, Radio, Space, Tooltip } from 'antd'
|
||||
import { BotMessageSquare, Plus, Zap } from 'lucide-react'
|
||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
||||
@ -20,21 +23,16 @@ interface Props {
|
||||
ref?: React.RefObject<QuickPhrasesButtonRef | null>
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
||||
resizeTextArea: () => void
|
||||
ToolbarButton: any
|
||||
assistantObj: Assistant
|
||||
assistantId: string
|
||||
}
|
||||
|
||||
const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton, assistantObj }: Props) => {
|
||||
const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, assistantId }: Props) => {
|
||||
const [quickPhrasesList, setQuickPhrasesList] = useState<QuickPhrase[]>([])
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [formData, setFormData] = useState({ title: '', content: '', location: 'global' })
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
const activeAssistantId = useAppSelector(
|
||||
(state) =>
|
||||
state.assistants.assistants.find((a) => a.id === assistantObj.id)?.id || state.assistants.defaultAssistant.id
|
||||
)
|
||||
const { assistant, updateAssistant } = useAssistant(activeAssistantId)
|
||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
const loadQuickListPhrases = useCallback(
|
||||
@ -135,7 +133,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton,
|
||||
() => ({
|
||||
title: t('settings.quickPhrase.title'),
|
||||
list: phraseItems,
|
||||
symbol: 'quick-phrases'
|
||||
symbol: QuickPanelReservedSymbol.QuickPhrases
|
||||
}),
|
||||
[phraseItems, t]
|
||||
)
|
||||
@ -145,7 +143,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton,
|
||||
}, [quickPanel, quickPanelOpenOptions])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === 'quick-phrases') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.QuickPhrases) {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
@ -159,9 +157,9 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton,
|
||||
return (
|
||||
<>
|
||||
<Tooltip placement="top" title={t('settings.quickPhrase.title')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
<ActionIconButton onClick={handleOpenQuickPanel}>
|
||||
<Zap size={18} />
|
||||
</ToolbarButton>
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Modal
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import {
|
||||
MdiLightbulbAutoOutline,
|
||||
MdiLightbulbOffOutline,
|
||||
@ -6,11 +7,11 @@ import {
|
||||
MdiLightbulbOn50,
|
||||
MdiLightbulbOn80
|
||||
} from '@renderer/components/Icons/SVGIcon'
|
||||
import { useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { getThinkModelType, isDoubaoThinkingAutoModel, MODEL_SUPPORTED_OPTIONS } from '@renderer/config/models'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { getReasoningEffortOptionsLabel } from '@renderer/i18n/label'
|
||||
import { Assistant, Model, ThinkingOption } from '@renderer/types'
|
||||
import { Model, ThinkingOption } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { FC, ReactElement, useCallback, useImperativeHandle, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -22,14 +23,13 @@ export interface ThinkingButtonRef {
|
||||
interface Props {
|
||||
ref?: React.RefObject<ThinkingButtonRef | null>
|
||||
model: Model
|
||||
assistant: Assistant
|
||||
ToolbarButton: any
|
||||
assistantId: string
|
||||
}
|
||||
|
||||
const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): ReactElement => {
|
||||
const ThinkingButton: FC<Props> = ({ ref, model, assistantId }): ReactElement => {
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
const { updateAssistantSettings } = useAssistant(assistant.id)
|
||||
const { assistant, updateAssistantSettings } = useAssistant(assistantId)
|
||||
|
||||
const currentReasoningEffort = useMemo(() => {
|
||||
return assistant.settings?.reasoning_effort || 'off'
|
||||
@ -49,27 +49,6 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
return MODEL_SUPPORTED_OPTIONS[modelType]
|
||||
}, [model, modelType])
|
||||
|
||||
const createThinkingIcon = useCallback((option?: ThinkingOption, isActive: boolean = false) => {
|
||||
const iconColor = isActive ? 'var(--color-primary)' : 'var(--color-icon)'
|
||||
|
||||
switch (true) {
|
||||
case option === 'minimal':
|
||||
return <MdiLightbulbOn30 width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
|
||||
case option === 'low':
|
||||
return <MdiLightbulbOn50 width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
|
||||
case option === 'medium':
|
||||
return <MdiLightbulbOn80 width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
|
||||
case option === 'high':
|
||||
return <MdiLightbulbOn width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
|
||||
case option === 'auto':
|
||||
return <MdiLightbulbAutoOutline width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
|
||||
case option === 'off':
|
||||
return <MdiLightbulbOffOutline width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
|
||||
default:
|
||||
return <MdiLightbulbOffOutline width={18} height={18} style={{ color: iconColor }} />
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onThinkingChange = useCallback(
|
||||
(option?: ThinkingOption) => {
|
||||
const isEnabled = option !== undefined && option !== 'off'
|
||||
@ -98,11 +77,11 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
level: option,
|
||||
label: getReasoningEffortOptionsLabel(option),
|
||||
description: '',
|
||||
icon: createThinkingIcon(option),
|
||||
icon: ThinkingIcon(option),
|
||||
isSelected: currentReasoningEffort === option,
|
||||
action: () => onThinkingChange(option)
|
||||
}))
|
||||
}, [createThinkingIcon, currentReasoningEffort, supportedOptions, onThinkingChange])
|
||||
}, [currentReasoningEffort, supportedOptions, onThinkingChange])
|
||||
|
||||
const isThinkingEnabled = currentReasoningEffort !== undefined && currentReasoningEffort !== 'off'
|
||||
|
||||
@ -114,12 +93,12 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
quickPanel.open({
|
||||
title: t('assistants.settings.reasoning_effort.label'),
|
||||
list: panelItems,
|
||||
symbol: 'thinking'
|
||||
symbol: QuickPanelReservedSymbol.Thinking
|
||||
})
|
||||
}, [quickPanel, panelItems, t])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === 'thinking') {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Thinking) {
|
||||
quickPanel.close()
|
||||
return
|
||||
}
|
||||
@ -131,12 +110,6 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
openQuickPanel()
|
||||
}, [openQuickPanel, quickPanel, isThinkingEnabled, supportedOptions, disableThinking])
|
||||
|
||||
// 获取当前应显示的图标
|
||||
const getThinkingIcon = useCallback(() => {
|
||||
// 不再判断选项是否支持,依赖 useAssistant 更新选项为支持选项的行为
|
||||
return createThinkingIcon(currentReasoningEffort, currentReasoningEffort !== 'off')
|
||||
}, [createThinkingIcon, currentReasoningEffort])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
@ -151,11 +124,41 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
|
||||
{getThinkingIcon()}
|
||||
</ToolbarButton>
|
||||
<ActionIconButton onClick={handleOpenQuickPanel} active={currentReasoningEffort !== 'off'}>
|
||||
{ThinkingIcon(currentReasoningEffort)}
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const ThinkingIcon = (option?: ThinkingOption) => {
|
||||
let IconComponent: React.FC<React.SVGProps<SVGSVGElement>> | null = null
|
||||
|
||||
switch (option) {
|
||||
case 'minimal':
|
||||
IconComponent = MdiLightbulbOn30
|
||||
break
|
||||
case 'low':
|
||||
IconComponent = MdiLightbulbOn50
|
||||
break
|
||||
case 'medium':
|
||||
IconComponent = MdiLightbulbOn80
|
||||
break
|
||||
case 'high':
|
||||
IconComponent = MdiLightbulbOn
|
||||
break
|
||||
case 'auto':
|
||||
IconComponent = MdiLightbulbAutoOutline
|
||||
break
|
||||
case 'off':
|
||||
IconComponent = MdiLightbulbOffOutline
|
||||
break
|
||||
default:
|
||||
IconComponent = MdiLightbulbOffOutline
|
||||
break
|
||||
}
|
||||
|
||||
return <IconComponent className="icon" width={18} height={18} style={{ marginTop: -2 }} />
|
||||
}
|
||||
|
||||
export default ThinkingButton
|
||||
|
||||
@ -11,7 +11,6 @@ type Props = {
|
||||
estimateTokenCount: number
|
||||
inputTokenCount: number
|
||||
contextCount: { current: number; max: number }
|
||||
ToolbarButton: any
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCount }) => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { isToolUseModeFunction } from '@renderer/utils/assistant'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Link } from 'lucide-react'
|
||||
@ -13,13 +13,12 @@ export interface UrlContextButtonRef {
|
||||
|
||||
interface Props {
|
||||
ref?: React.RefObject<UrlContextButtonRef | null>
|
||||
assistant: Assistant
|
||||
ToolbarButton: any
|
||||
assistantId: string
|
||||
}
|
||||
|
||||
const UrlContextButton: FC<Props> = ({ assistant, ToolbarButton }) => {
|
||||
const UrlContextButton: FC<Props> = ({ assistantId }) => {
|
||||
const { t } = useTranslation()
|
||||
const { updateAssistant } = useAssistant(assistant.id)
|
||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
const urlContentNewState = !assistant.enableUrlContext
|
||||
@ -48,14 +47,9 @@ const UrlContextButton: FC<Props> = ({ assistant, ToolbarButton }) => {
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('chat.input.url_context')} arrow>
|
||||
<ToolbarButton type="text" onClick={handleToggle}>
|
||||
<Link
|
||||
size={18}
|
||||
style={{
|
||||
color: assistant.enableUrlContext ? 'var(--color-primary)' : 'var(--color-icon)'
|
||||
}}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
<ActionIconButton onClick={handleToggle} active={assistant.enableUrlContext}>
|
||||
<Link size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user