diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 352cdab2..ad60467f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,11 +2,6 @@ name: Release on: workflow_dispatch: - inputs: - version: - description: 'Version (e.g. v1.2.3)' - required: true - type: string push: tags: - v*.*.* @@ -20,7 +15,15 @@ jobs: strategy: matrix: - os: [macos-latest, windows-latest, ubuntu-latest] + os: [macos-13, macos-latest, windows-latest, ubuntu-latest] + arch: [x64, arm64] + exclude: + - os: windows-latest + arch: arm64 + - os: macos-latest + arch: x64 + - os: macos-13 + arch: arm64 steps: - name: Check out Git repository @@ -30,10 +33,25 @@ jobs: uses: actions/setup-node@v3 with: node-version: 20 + arch: ${{ matrix.arch }} - name: Install corepack run: corepack enable && corepack prepare yarn@4.3.1 --activate + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + + - name: Cache yarn dependencies + uses: actions/cache@v3 + with: + path: | + ${{ steps.yarn-cache-dir-path.outputs.dir }} + node_modules + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: Install Dependencies run: yarn install @@ -42,10 +60,11 @@ jobs: run: yarn build:linux env: GH_TOKEN: ${{ secrets.GH_TOKEN }} + ARCH: ${{ matrix.arch }} - name: Build Mac - if: matrix.os == 'macos-latest' - run: yarn build:mac + if: matrix.os == 'macos-13' || matrix.os == 'macos-latest' + run: yarn build:mac && mv dist/latest-mac.yml dist/latest-mac-${{ matrix.arch }}.yml env: CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} @@ -53,12 +72,14 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} GH_TOKEN: ${{ secrets.GH_TOKEN }} + ARCH: ${{ matrix.arch }} - name: Build Windows if: matrix.os == 'windows-latest' run: yarn build:win env: GH_TOKEN: ${{ secrets.GH_TOKEN }} + ARCH: ${{ matrix.arch }} - name: Replace spaces in filenames run: node scripts/replaceSpaces.js diff --git a/.gitignore b/.gitignore index a38c9c13..78bc5b83 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ node_modules dist out build/icons +stats.html # ENV .env diff --git a/.yarn/patches/@electron-notarize-npm-2.3.2-535908a4bd.patch b/.yarn/patches/@electron-notarize-npm-2.3.2-535908a4bd.patch deleted file mode 100644 index 6df0a904..00000000 --- a/.yarn/patches/@electron-notarize-npm-2.3.2-535908a4bd.patch +++ /dev/null @@ -1,53 +0,0 @@ -diff --git a/lib/check-signature.js b/lib/check-signature.js -index 324568af71bcc4372c9f959131ecd24122848c86..677348e0a138ff608b2ac41f592d813b15ee4956 100644 ---- a/lib/check-signature.js -+++ b/lib/check-signature.js -@@ -41,16 +41,12 @@ const spawn_1 = require("./spawn"); - const debug_1 = __importDefault(require("debug")); - const d = (0, debug_1.default)('electron-notarize'); - const codesignDisplay = (opts) => __awaiter(void 0, void 0, void 0, function* () { -- const result = yield (0, spawn_1.spawn)('codesign', ['-dv', '-vvvv', '--deep', path.basename(opts.appPath)], { -- cwd: path.dirname(opts.appPath), -- }); -+ const result = yield (0, spawn_1.spawn)('codesign', ['-dv', '-vvvv', '--deep', opts.appPath]); - return result; - }); - const codesign = (opts) => __awaiter(void 0, void 0, void 0, function* () { - d('attempting to check codesign of app:', opts.appPath); -- const result = yield (0, spawn_1.spawn)('codesign', ['-vvv', '--deep', '--strict', path.basename(opts.appPath)], { -- cwd: path.dirname(opts.appPath), -- }); -+ const result = yield (0, spawn_1.spawn)('codesign', ['-vvv', '--deep', '--strict', opts.appPath]); - return result; - }); - function checkSignatures(opts) { -diff --git a/lib/notarytool.js b/lib/notarytool.js -index 1ab090efb2101fc8bee5553445e0349c54474421..a5ddfd922197449fc56078e4a7e9a2ee5d8d207d 100644 ---- a/lib/notarytool.js -+++ b/lib/notarytool.js -@@ -92,9 +92,7 @@ function notarizeAndWaitForNotaryTool(opts) { - else { - filePath = path.resolve(dir, `${path.parse(opts.appPath).name}.zip`); - d('zipping application to:', filePath); -- const zipResult = yield (0, spawn_1.spawn)('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', path.basename(opts.appPath), filePath], { -- cwd: path.dirname(opts.appPath), -- }); -+ const zipResult = yield (0, spawn_1.spawn)('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', opts.appPath, filePath]); - if (zipResult.code !== 0) { - throw new Error(`Failed to zip application, exited with code: ${zipResult.code}\n\n${zipResult.output}`); - } -diff --git a/lib/staple.js b/lib/staple.js -index 47dbd85b2fc279d999b57f47fb8171e1cc674436..f8829e6ac54fcd630a730d12d75acc1591b953b6 100644 ---- a/lib/staple.js -+++ b/lib/staple.js -@@ -43,9 +43,7 @@ const d = (0, debug_1.default)('electron-notarize:staple'); - function stapleApp(opts) { - return __awaiter(this, void 0, void 0, function* () { - d('attempting to staple app:', opts.appPath); -- const result = yield (0, spawn_1.spawn)('xcrun', ['stapler', 'staple', '-v', path.basename(opts.appPath)], { -- cwd: path.dirname(opts.appPath), -- }); -+ const result = yield (0, spawn_1.spawn)('xcrun', ['stapler', 'staple', '-v', opts.appPath]); - if (result.code !== 0) { - throw new Error(`Failed to staple your application with code: ${result.code}\n\n${result.output}`); - } diff --git a/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch b/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch new file mode 100644 index 00000000..c918d2f3 --- /dev/null +++ b/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch @@ -0,0 +1,29 @@ +diff --git a/lib/pdf-parse.js b/lib/pdf-parse.js +index 96bfbc705dcb4fb73cb077a75f02c115371b3477..6d02d2bb426063c3a31cb740c3d86841de162a22 100644 +--- a/lib/pdf-parse.js ++++ b/lib/pdf-parse.js +@@ -21,12 +21,12 @@ function render_page(pageData) { + for (let item of textContent.items) { + if (lastY == item.transform[5] || !lastY){ + text += item.str; +- } ++ } + else{ + text += '\n' + item.str; +- } ++ } + lastY = item.transform[5]; +- } ++ } + //let strings = textContent.items.map(item => item.str); + //let text = strings.join("\n"); + //text = text.replace(/[ ]+/ig," "); +@@ -60,7 +60,7 @@ async function PDF(dataBuffer, options) { + if (typeof options.version != 'string') options.version = DEFAULT_OPTIONS.version; + if (options.version == 'default') options.version = DEFAULT_OPTIONS.version; + +- PDFJS = PDFJS ? PDFJS : require(`./pdf.js/${options.version}/build/pdf.js`); ++ PDFJS = PDFJS ? PDFJS : require(`./pdf.js/v1.10.100/build/pdf.js`); + + ret.version = PDFJS.version; + diff --git a/electron-builder.yml b/electron-builder.yml index bdb3f958..5970be70 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -11,8 +11,26 @@ files: - '!src' - '!scripts' - '!local' + - '!docs' + - '!packages' + - '!stats.html' + - '!*.md' + - '!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}' + - '!**/{test,tests,__tests__,coverage}/**' + - '!**/*.{spec,test}.{js,jsx,ts,tsx}' + - '!**/*.min.*.map' + - '!**/*.d.ts' + - '!**/{.DS_Store,Thumbs.db}' + - '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,README.md,CHANGELOG.md}' + - '!node_modules/rollup-plugin-visualizer' + - '!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/html2canvas/dist/{html2canvas.min.js,html2canvas.esm.js}' + asarUnpack: - resources/** + - '**/*.{node,dll,metal,exp,lib}' win: executableName: Cherry Studio nsis: @@ -30,46 +48,22 @@ mac: - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. notarize: false - target: - - target: dmg - arch: - - arm64 - - x64 - - target: zip - arch: - - arm64 - - x64 dmg: artifactName: ${productName}-${version}-${arch}.${ext} linux: target: - target: AppImage - arch: - - arm64 - - x64 - # - snap - # - deb maintainer: electronjs.org category: Utility appImage: artifactName: ${productName}-${version}-${arch}.${ext} -npmRebuild: false publish: provider: generic url: https://cherrystudio.ocool.online electronDownload: mirror: https://npmmirror.com/mirrors/electron/ +afterPack: scripts/removeLocales.js afterSign: scripts/notarize.js releaseInfo: releaseNotes: | - 增加小程序快捷入口 - 增加ThinkAny、纳米搜索小程序 - 优化 Markdown 列表显示 - 可以多次点击上传文件按钮上传文件 - 大屏幕默认使用更大的输入框 - 设置中增加显示设置模块 - 支持 SVG 预览 - Mermaid 图表支持复制源码 - 增加复制最后一条消息快捷键 - o1模型默认开启流式输出 - 长文本粘贴为文件支持修改阈值 + 增加知识库功能 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 415b6879..9dc98420 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,23 +1,49 @@ import react from '@vitejs/plugin-react' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import { resolve } from 'path' +import { visualizer } from 'rollup-plugin-visualizer' + +const visualizerPlugin = (type: 'renderer' | 'main') => { + return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : [] +} export default defineConfig({ main: { - plugins: [externalizeDepsPlugin()], + plugins: [ + externalizeDepsPlugin({ + exclude: [ + '@llm-tools/embedjs', + '@llm-tools/embedjs-openai', + '@llm-tools/embedjs-loader-web', + '@llm-tools/embedjs-loader-markdown', + '@llm-tools/embedjs-loader-msoffice', + '@llm-tools/embedjs-loader-xml', + '@llm-tools/embedjs-loader-pdf', + '@llm-tools/embedjs-loader-sitemap', + '@llm-tools/embedjs-libsql' + ] + }), + ...visualizerPlugin('main') + ], resolve: { alias: { '@main': resolve('src/main'), '@types': resolve('src/renderer/src/types'), '@shared': resolve('packages/shared') } + }, + build: { + rollupOptions: { + external: ['@libsql/client'] + }, + minify: true } }, preload: { plugins: [externalizeDepsPlugin()] }, renderer: { - plugins: [react()], + plugins: [react(), ...visualizerPlugin('renderer')], resolve: { alias: { '@renderer': resolve('src/renderer/src'), @@ -26,6 +52,9 @@ export default defineConfig({ }, optimizeDeps: { exclude: ['chunk-7UIZINC5.js', 'chunk-7OJJKI46.js'] + }, + build: { + minify: true } } }) diff --git a/package.json b/package.json index e4f61c8b..58ce2b5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "0.8.27", + "version": "0.9.0", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -11,9 +11,11 @@ "local", "packages/*" ], - "nohoist": [ - "packages/database" - ] + "installConfig": { + "hoistingLimits": [ + "packages/database" + ] + } }, "scripts": { "format": "prettier --write .", @@ -23,12 +25,19 @@ "typecheck": "npm run typecheck:node && npm run typecheck:web", "start": "electron-vite preview", "dev": "electron-vite dev", + "analyze:renderer": "VISUALIZER_RENDERER=true yarn build", + "analyze:main": "VISUALIZER_MAIN=true yarn build", "build": "npm run typecheck && electron-vite build", "postinstall": "electron-builder install-app-deps", "build:unpack": "dotenv npm run build && electron-builder --dir", - "build:win": "dotenv npm run build && electron-builder --win --publish never", - "build:mac": "dotenv electron-vite build && electron-builder --mac --publish never", - "build:linux": "dotenv electron-vite build && electron-builder --linux --publish never", + "build:win": "dotenv npm run build && electron-builder --win --$ARCH", + "build:win:x64": "dotenv npm run build && electron-builder --win --x64", + "build:mac": "dotenv electron-vite build && electron-builder --mac --$ARCH", + "build:mac:arm64": "dotenv electron-vite build && electron-builder --mac --arm64", + "build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64", + "build:linux": "dotenv electron-vite build && electron-builder --linux --$ARCH", + "build:linux:arm64": "dotenv electron-vite build && electron-builder --linux --arm64", + "build:linux:x64": "dotenv electron-vite build && electron-builder --linux --x64", "release": "node scripts/version.js", "publish": "yarn release patch push", "pulish:artifacts": "cd packages/artifacts && npm publish && cd -", @@ -38,6 +47,18 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", + "@electron/notarize": "^2.5.0", + "@llm-tools/embedjs": "^0.1.25", + "@llm-tools/embedjs-libsql": "^0.1.25", + "@llm-tools/embedjs-loader-csv": "^0.1.25", + "@llm-tools/embedjs-loader-markdown": "^0.1.25", + "@llm-tools/embedjs-loader-msoffice": "^0.1.25", + "@llm-tools/embedjs-loader-pdf": "^0.1.25", + "@llm-tools/embedjs-loader-sitemap": "^0.1.25", + "@llm-tools/embedjs-loader-web": "^0.1.25", + "@llm-tools/embedjs-loader-xml": "^0.1.25", + "@llm-tools/embedjs-openai": "^0.1.25", + "@types/react-infinite-scroll-component": "^5.0.0", "adm-zip": "^0.5.16", "docx": "^9.0.2", "electron-log": "^5.1.5", @@ -48,6 +69,7 @@ "html2canvas": "^1.4.1", "markdown-it": "^14.1.0", "officeparser": "^4.1.1", + "tokenx": "^0.4.1", "webdav": "4.11.4" }, "devDependencies": { @@ -88,7 +110,6 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.0.0", - "gpt-tokens": "^1.3.10", "i18next": "^23.11.5", "lodash": "^4.17.21", "mime": "^4.0.4", @@ -111,6 +132,7 @@ "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", + "rollup-plugin-visualizer": "^5.12.0", "sass": "^1.77.2", "shiki": "^1.22.2", "styled-components": "^6.1.11", @@ -124,7 +146,7 @@ "react-dom": "^17.0.0 || ^18.0.0" }, "resolutions": { - "@electron/notarize@npm:2.2.1": "patch:@electron/notarize@npm%3A2.3.2#~/.yarn/patches/@electron-notarize-npm-2.3.2-535908a4bd.patch" + "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch" }, "packageManager": "yarn@4.5.0" } diff --git a/scripts/cloudflare-worker.js b/scripts/cloudflare-worker.js index 283c0383..27cd86ec 100644 --- a/scripts/cloudflare-worker.js +++ b/scripts/cloudflare-worker.js @@ -6,510 +6,558 @@ const config = { CACHE_KEY: 'cherry-studio-latest-release', VERSION_DB: 'versions.json', LOG_FILE: 'logs.json', - MAX_LOGS: 1000 // 最多保存多少条日志 -}; + MAX_LOGS: 1000 // 最多保存多少条日志 +} // Worker 入口函数 const worker = { // 定时器触发配置 scheduled: { - cron: '*/1 * * * *' // 每分钟执行一次 + cron: '*/1 * * * *' // 每分钟执行一次 }, // 定时器执行函数 - 只负责检查和更新 async scheduled(event, env, ctx) { - try { - await initDataFiles(env); - console.log('开始定时检查新版本...'); - // 使用新的 checkNewRelease 函数 - await checkNewRelease(env); - } catch (error) { - console.error('定时任务执行失败:', error); - } + try { + await initDataFiles(env) + console.log('开始定时检查新版本...') + // 使用新的 checkNewRelease 函数 + await checkNewRelease(env) + } catch (error) { + console.error('定时任务执行失败:', error) + } }, // HTTP 请求处理函数 - 只负责返回数据 async fetch(request, env, ctx) { - if (!env || !env.R2_BUCKET) { - return new Response(JSON.stringify({ - error: 'R2 存储桶未正确配置' - }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); + if (!env || !env.R2_BUCKET) { + return new Response( + JSON.stringify({ + error: 'R2 存储桶未正确配置' + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + } + ) + } + + const url = new URL(request.url) + const filename = url.pathname.slice(1) + + try { + // 处理文件下载请求 + if (filename) { + return await handleDownload(env, filename) } - const url = new URL(request.url); - const filename = url.pathname.slice(1); - - try { - // 处理文件下载请求 - if (filename) { - return await handleDownload(env, filename); - } - - // 只返回缓存的版本信息 - return await getCachedRelease(env); - } catch (error) { - return new Response(JSON.stringify({ - error: error.message, - stack: error.stack - }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); - } + // 只返回缓存的版本信息 + return await getCachedRelease(env) + } catch (error) { + return new Response( + JSON.stringify({ + error: error.message, + stack: error.stack + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + } + ) + } } -}; +} -export default worker; +export default worker /** -* 添加日志记录函数 -*/ + * 添加日志记录函数 + */ async function addLog(env, type, event, details = null) { try { - const logFile = await env.R2_BUCKET.get(config.LOG_FILE); - let logs = { logs: [] }; + const logFile = await env.R2_BUCKET.get(config.LOG_FILE) + let logs = { logs: [] } - if (logFile) { - logs = JSON.parse(await logFile.text()); - } + if (logFile) { + logs = JSON.parse(await logFile.text()) + } - logs.logs.unshift({ - timestamp: new Date().toISOString(), - type, - event, - details - }); + logs.logs.unshift({ + timestamp: new Date().toISOString(), + type, + event, + details + }) - // 保持日志数量在限制内 - if (logs.logs.length > config.MAX_LOGS) { - logs.logs = logs.logs.slice(0, config.MAX_LOGS); - } + // 保持日志数量在限制内 + if (logs.logs.length > config.MAX_LOGS) { + logs.logs = logs.logs.slice(0, config.MAX_LOGS) + } - await env.R2_BUCKET.put(config.LOG_FILE, JSON.stringify(logs, null, 2)); + await env.R2_BUCKET.put(config.LOG_FILE, JSON.stringify(logs, null, 2)) } catch (error) { - console.error('写入日志失败:', error); + console.error('写入日志失败:', error) } } /** -* 获取最新版本信息 -*/ + * 获取最新版本信息 + */ async function getLatestRelease(env) { try { - const cached = await env.R2_BUCKET.get(config.CACHE_KEY); - if (!cached) { - // 如果缓存不存在,先检查版本数据库 - const versionDB = await env.R2_BUCKET.get(config.VERSION_DB); - if (versionDB) { - const versions = JSON.parse(await versionDB.text()); - if (versions.latestVersion) { - // 从版本数据库重建缓存 - const latestVersion = versions.versions[versions.latestVersion]; - const cacheData = { - version: latestVersion.version, - publishedAt: latestVersion.publishedAt, - changelog: latestVersion.changelog, - downloads: latestVersion.files - .filter(file => file.uploaded) - .map(file => ({ - name: file.name, - url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`, - size: formatFileSize(file.size) - })) - }; - // 更新缓存 - await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData)); - return new Response(JSON.stringify(cacheData), { - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*' - } - }); - } + const cached = await env.R2_BUCKET.get(config.CACHE_KEY) + if (!cached) { + // 如果缓存不存在,先检查版本数据库 + const versionDB = await env.R2_BUCKET.get(config.VERSION_DB) + if (versionDB) { + const versions = JSON.parse(await versionDB.text()) + if (versions.latestVersion) { + // 从版本数据库重建缓存 + const latestVersion = versions.versions[versions.latestVersion] + const cacheData = { + version: latestVersion.version, + publishedAt: latestVersion.publishedAt, + changelog: latestVersion.changelog, + downloads: latestVersion.files + .filter((file) => file.uploaded) + .map((file) => ({ + name: file.name, + url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`, + size: formatFileSize(file.size) + })) } - // 如果版本数据库也没有数据,才执行检查更新 - const data = await checkNewRelease(env); - return new Response(JSON.stringify(data), { - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*' - } - }); + // 更新缓存 + await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData)) + return new Response(JSON.stringify(cacheData), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }) + } } + // 如果版本数据库也没有数据,才执行检查更新 + const data = await checkNewRelease(env) + return new Response(JSON.stringify(data), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }) + } - const data = await cached.text(); - return new Response(data, { - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*' - } - }); + const data = await cached.text() + return new Response(data, { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }) } catch (error) { - await addLog(env, 'ERROR', '获取版本信息失败', error.message); - return new Response(JSON.stringify({ - error: '获取版本信息失败: ' + error.message, - detail: '请稍���再试' - }), { - status: 500, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*' - } - }); + await addLog(env, 'ERROR', '获取版本信息失败', error.message) + return new Response( + JSON.stringify({ + error: '获取版本信息失败: ' + error.message, + detail: '请稍后再试' + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + } + ) } } // 修改下载处理函数,直接接收 env async function handleDownload(env, filename) { try { - const object = await env.R2_BUCKET.get(filename); + const object = await env.R2_BUCKET.get(filename) - if (!object) { - return new Response('文件未找到', { status: 404 }); - } + if (!object) { + return new Response('文件未找到', { status: 404 }) + } - // 设置响应头 - const headers = new Headers(); - object.writeHttpMetadata(headers); - headers.set('etag', object.httpEtag); - headers.set('Content-Disposition', `attachment; filename="${filename}"`); + // 设置响应头 + const headers = new Headers() + object.writeHttpMetadata(headers) + headers.set('etag', object.httpEtag) + headers.set('Content-Disposition', `attachment; filename="${filename}"`) - return new Response(object.body, { - headers - }); + return new Response(object.body, { + headers + }) } catch (error) { - console.error('下载文件时发生错误:', error); - return new Response('获取文件失败', { status: 500 }); + console.error('下载文件时发生错误:', error) + return new Response('获取文件失败', { status: 500 }) } } /** -* 根据文件扩展名获取对应的 Content-Type -*/ + * 根据文件扩展名获取对应的 Content-Type + */ function getContentType(filename) { - const ext = filename.split('.').pop().toLowerCase(); + const ext = filename.split('.').pop().toLowerCase() const types = { - 'exe': 'application/x-msdownload', // Windows 可执行文件 - 'dmg': 'application/x-apple-diskimage', // macOS 安装包 - 'zip': 'application/zip', // 压缩包 - 'AppImage': 'application/x-executable', // Linux 可执行文件 - 'blockmap': 'application/octet-stream' // 更新文件 - }; - return types[ext] || 'application/octet-stream'; + exe: 'application/x-msdownload', // Windows 可执行文件 + dmg: 'application/x-apple-diskimage', // macOS 安装包 + zip: 'application/zip', // 压缩包 + AppImage: 'application/x-executable', // Linux 可执行文件 + blockmap: 'application/octet-stream' // 更新文件 + } + return types[ext] || 'application/octet-stream' } /** -* 格式化文件大小 -* 将字节转换为人类可读的格式(B, KB, MB, GB) -*/ + * 格式化文件大小 + * 将字节转换为人类可读的格式(B, KB, MB, GB) + */ function formatFileSize(bytes) { - const units = ['B', 'KB', 'MB', 'GB']; - let size = bytes; - let unitIndex = 0; + const units = ['B', 'KB', 'MB', 'GB'] + let size = bytes + let unitIndex = 0 while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; + size /= 1024 + unitIndex++ } - return `${size.toFixed(2)} ${units[unitIndex]}`; + return `${size.toFixed(2)} ${units[unitIndex]}` } /** -* 版本号比较函数 -* 用于对版本号进行排序 -*/ + * 版本号比较函数 + * 用于对版本号进行排序 + */ function compareVersions(a, b) { - const partsA = a.replace('v', '').split('.'); - const partsB = b.replace('v', '').split('.'); + const partsA = a.replace('v', '').split('.') + const partsB = b.replace('v', '').split('.') for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { - const numA = parseInt(partsA[i] || 0); - const numB = parseInt(partsB[i] || 0); + const numA = parseInt(partsA[i] || 0) + const numB = parseInt(partsB[i] || 0) - if (numA !== numB) { - return numA - numB; - } + if (numA !== numB) { + return numA - numB + } } - return 0; + return 0 } /** -* 初始化数据文件 -*/ + * 初始化数据文件 + */ async function initDataFiles(env) { try { - // 检查并初始化版本数据库 - const versionDB = await env.R2_BUCKET.get(config.VERSION_DB); - if (!versionDB) { - const initialVersions = { - versions: {}, - latestVersion: null, - lastChecked: new Date().toISOString() - }; - await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(initialVersions, null, 2)); - await addLog(env, 'INFO', 'versions.json 初始化成功'); + // 检查并初始化版本数据库 + const versionDB = await env.R2_BUCKET.get(config.VERSION_DB) + if (!versionDB) { + const initialVersions = { + versions: {}, + latestVersion: null, + lastChecked: new Date().toISOString() } + await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(initialVersions, null, 2)) + await addLog(env, 'INFO', 'versions.json 初始化成功') + } - // 检查并初始化日志文件 - const logFile = await env.R2_BUCKET.get(config.LOG_FILE); - if (!logFile) { - const initialLogs = { - logs: [{ - timestamp: new Date().toISOString(), - type: 'INFO', - event: '系统初始化' - }] - }; - await env.R2_BUCKET.put(config.LOG_FILE, JSON.stringify(initialLogs, null, 2)); - console.log('logs.json 初始化成功'); + // 检查并初始化日志文件 + const logFile = await env.R2_BUCKET.get(config.LOG_FILE) + if (!logFile) { + const initialLogs = { + logs: [ + { + timestamp: new Date().toISOString(), + type: 'INFO', + event: '系统初始化' + } + ] } + await env.R2_BUCKET.put(config.LOG_FILE, JSON.stringify(initialLogs, null, 2)) + console.log('logs.json 初始化成功') + } } catch (error) { - console.error('初始化数据文件失败:', error); + console.error('初始化数据文件失败:', error) } } // 新增:只获取缓存的版本信息 async function getCachedRelease(env) { try { - const cached = await env.R2_BUCKET.get(config.CACHE_KEY); - if (!cached) { - // 如果缓存不存在,从版本数据库获取 - const versionDB = await env.R2_BUCKET.get(config.VERSION_DB); - if (versionDB) { - const versions = JSON.parse(await versionDB.text()); - if (versions.latestVersion) { - const latestVersion = versions.versions[versions.latestVersion]; - const cacheData = { - version: latestVersion.version, - publishedAt: latestVersion.publishedAt, - changelog: latestVersion.changelog, - downloads: latestVersion.files - .filter(file => file.uploaded) - .map(file => ({ - name: file.name, - url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`, - size: formatFileSize(file.size) - })) - }; - // 重建缓存 - await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData)); - return new Response(JSON.stringify(cacheData), { - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*' - } - }); - } + const cached = await env.R2_BUCKET.get(config.CACHE_KEY) + if (!cached) { + // 如果缓存不存在,从版本数据库获取 + const versionDB = await env.R2_BUCKET.get(config.VERSION_DB) + if (versionDB) { + const versions = JSON.parse(await versionDB.text()) + if (versions.latestVersion) { + const latestVersion = versions.versions[versions.latestVersion] + const cacheData = { + version: latestVersion.version, + publishedAt: latestVersion.publishedAt, + changelog: latestVersion.changelog, + downloads: latestVersion.files + .filter((file) => file.uploaded) + .map((file) => ({ + name: file.name, + url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`, + size: formatFileSize(file.size) + })) } - // 如果没有任何数据,返回错误 - return new Response(JSON.stringify({ - error: '没有可用的版本信息' - }), { - status: 404, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*' - } - }); - } - - // 返回缓存数据 - return new Response(await cached.text(), { - headers: { + // 重建缓存 + await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData)) + return new Response(JSON.stringify(cacheData), { + headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' + } + }) + } + } + // 如果没有任何数据,返回错误 + return new Response( + JSON.stringify({ + error: '没有可用的版本信息' + }), + { + status: 404, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' } - }); + } + ) + } + + // 返回缓存数据 + return new Response(await cached.text(), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }) } catch (error) { - await addLog(env, 'ERROR', '获取缓存版本信息失败', error.message); - throw error; + await addLog(env, 'ERROR', '获取缓存版本信息失败', error.message) + throw error } } // 新增:只检查新版本并更新 async function checkNewRelease(env) { try { - // 获取 GitHub 最新版本 - const githubResponse = await fetch('https://api.github.com/repos/kangfenmao/cherry-studio/releases/latest', { - headers: { 'User-Agent': 'CloudflareWorker' }, - }); + // 获取 GitHub 最新版本 + const githubResponse = await fetch('https://api.github.com/repos/kangfenmao/cherry-studio/releases/latest', { + headers: { 'User-Agent': 'CloudflareWorker' } + }) - if (!githubResponse.ok) { - throw new Error('GitHub API 请求失败'); - } + if (!githubResponse.ok) { + throw new Error('GitHub API 请求失败') + } - const releaseData = await githubResponse.json(); - const version = releaseData.tag_name; + const releaseData = await githubResponse.json() + const version = releaseData.tag_name - // 获取版本数据库 - const versionDB = await env.R2_BUCKET.get(config.VERSION_DB); - let versions = { versions: {}, latestVersion: null, lastChecked: new Date().toISOString() }; + // 获取版本数据库 + const versionDB = await env.R2_BUCKET.get(config.VERSION_DB) + let versions = { versions: {}, latestVersion: null, lastChecked: new Date().toISOString() } - if (versionDB) { - versions = JSON.parse(await versionDB.text()); - } + if (versionDB) { + versions = JSON.parse(await versionDB.text()) + } - // 移除版本检查,改为记录是否有文件更新的标志 - let hasUpdates = false; - if (versions.latestVersion !== version) { - await addLog(env, 'INFO', `发现新版本: ${version}`); - hasUpdates = true; - } else { - await addLog(env, 'INFO', `版本 ${version} 文件完整性检查开始`); - } + // 移除版本检查,改为记录是否有文件更新的标志 + let hasUpdates = false + if (versions.latestVersion !== version) { + await addLog(env, 'INFO', `发现新版本: ${version}`) + hasUpdates = true + } else { + await addLog(env, 'INFO', `版本 ${version} 文件完整性检查开始`) + } - // 准备新版本记录 - const versionRecord = { - version, - publishedAt: releaseData.published_at, - uploadedAt: null, - files: releaseData.assets.map(asset => ({ - name: asset.name, - size: asset.size, - uploaded: false - })), - changelog: releaseData.body - }; + // 准备新版本记录 + const versionRecord = { + version, + publishedAt: releaseData.published_at, + uploadedAt: null, + files: releaseData.assets.map((asset) => ({ + name: asset.name, + size: asset.size, + uploaded: false + })), + changelog: releaseData.body + } - // 检查并上传文件 - for (const asset of releaseData.assets) { - try { - const existingFile = await env.R2_BUCKET.get(asset.name); - // 检查文件是否存在且大小是否一致 - if (!existingFile || existingFile.size !== asset.size) { - hasUpdates = true; - const response = await fetch(asset.browser_download_url); - if (!response.ok) { - throw new Error(`下载失败: HTTP ${response.status}`); - } - - const file = await response.arrayBuffer(); - await env.R2_BUCKET.put(asset.name, file, { - httpMetadata: { contentType: getContentType(asset.name) } - }); - - // 更新文件状态 - const fileIndex = versionRecord.files.findIndex(f => f.name === asset.name); - if (fileIndex !== -1) { - versionRecord.files[fileIndex].uploaded = true; - } - - await addLog(env, 'INFO', `文件${existingFile ? '更新' : '上传'}成功: ${asset.name}`); - } else { - // 文件存在且大小相同,标记为已上传 - const fileIndex = versionRecord.files.findIndex(f => f.name === asset.name); - if (fileIndex !== -1) { - versionRecord.files[fileIndex].uploaded = true; - } - await addLog(env, 'INFO', `文件完整性验证通过: ${asset.name}`); - } - } catch (error) { - await addLog(env, 'ERROR', `文件处理失败: ${asset.name}`, error.message); + // 检查并上传文件 + for (const asset of releaseData.assets) { + try { + const existingFile = await env.R2_BUCKET.get(asset.name) + // 检查文件是否存在且大小是否一致 + if (!existingFile || existingFile.size !== asset.size) { + hasUpdates = true + const response = await fetch(asset.browser_download_url) + if (!response.ok) { + throw new Error(`下载失败: HTTP ${response.status}`) } - } - // 只有在有更新或是新版本时才更新数据库和缓存 - if (hasUpdates) { - // 更新版本记录 - versionRecord.uploadedAt = new Date().toISOString(); - versions.versions[version] = versionRecord; - versions.latestVersion = version; + const file = await response.arrayBuffer() + await env.R2_BUCKET.put(asset.name, file, { + httpMetadata: { contentType: getContentType(asset.name) } + }) - // 保存版本数据库 - await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(versions, null, 2)); - - // 更新缓存 - const cacheData = { - version, - publishedAt: releaseData.published_at, - changelog: releaseData.body, - downloads: versionRecord.files - .filter(file => file.uploaded) - .map(file => ({ - name: file.name, - url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`, - size: formatFileSize(file.size) - })) - }; - - await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData)); - await addLog(env, 'INFO', hasUpdates ? '更新完成' : '文件完整性检查完成'); - - // 清理旧版本 - const versionList = Object.keys(versions.versions).sort((a, b) => compareVersions(b, a)); - if (versionList.length > 2) { - // 获取需要保留的两个最新版本 - const keepVersions = versionList.slice(0, 2); - // 获取所有需要删除的版本 - const oldVersions = versionList.slice(2); - - // 先获取 R2 桶中的所有文件列表 - const allFiles = await listAllFiles(env); - - // 获取需要保留的文件名列表 - const keepFiles = new Set(); - for (const keepVersion of keepVersions) { - const versionFiles = versions.versions[keepVersion].files; - versionFiles.forEach(file => keepFiles.add(file.name)); - } - - // 删除所有旧版本文件 - for (const oldVersion of oldVersions) { - const oldFiles = versions.versions[oldVersion].files; - for (const file of oldFiles) { - try { - if (file.uploaded) { - await env.R2_BUCKET.delete(file.name); - await addLog(env, 'INFO', `删除旧文件: ${file.name}`); - } - } catch (error) { - await addLog(env, 'ERROR', `删除旧文件失败: ${file.name}`, error.message); - } - } - delete versions.versions[oldVersion]; - } - - // 清理可能遗留的旧文件 - for (const file of allFiles) { - if (!keepFiles.has(file.name)) { - try { - await env.R2_BUCKET.delete(file.name); - await addLog(env, 'INFO', `删除遗留文件: ${file.name}`); - } catch (error) { - await addLog(env, 'ERROR', `删除遗留文件失败: ${file.name}`, error.message); - } - } - } - - // 保存更新后的版本数据库 - await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(versions, null, 2)); + // 更新文件状态 + const fileIndex = versionRecord.files.findIndex((f) => f.name === asset.name) + if (fileIndex !== -1) { + versionRecord.files[fileIndex].uploaded = true } - } else { - await addLog(env, 'INFO', '所有文件完整性检查通过,无需更新'); + + await addLog(env, 'INFO', `文件${existingFile ? '更新' : '上传'}成功: ${asset.name}`) + } else { + // 文件存在且大小相同,标记为已上传 + const fileIndex = versionRecord.files.findIndex((f) => f.name === asset.name) + if (fileIndex !== -1) { + versionRecord.files[fileIndex].uploaded = true + } + await addLog(env, 'INFO', `文件完整性验证通过: ${asset.name}`) + } + } catch (error) { + await addLog(env, 'ERROR', `文件处理失败: ${asset.name}`, error.message) + } + } + + // 只有在有更新或是新版本时才更新数据库和缓存 + if (hasUpdates) { + // 更新版本记录 + versionRecord.uploadedAt = new Date().toISOString() + versions.versions[version] = versionRecord + versions.latestVersion = version + + // 保存版本数据库 + await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(versions, null, 2)) + + // 更新缓存 + const cacheData = { + version, + publishedAt: releaseData.published_at, + changelog: releaseData.body, + downloads: versionRecord.files + .filter((file) => file.uploaded) + .map((file) => ({ + name: file.name, + url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`, + size: formatFileSize(file.size) + })) } - return hasUpdates ? cacheData : null; + await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData)) + await addLog(env, 'INFO', hasUpdates ? '更新完成' : '文件完整性检查完成') + + // 清理旧版本 + const versionList = Object.keys(versions.versions).sort((a, b) => compareVersions(b, a)) + if (versionList.length > 2) { + // 获取需要保留的两个最新版本 + const keepVersions = versionList.slice(0, 2) + // 获取所有需要删除的版本 + const oldVersions = versionList.slice(2) + + // 先获取 R2 桶中的所有文件列表 + const allFiles = await listAllFiles(env) + + // 获取需要保留的文件名列表 + const keepFiles = new Set() + for (const keepVersion of keepVersions) { + const versionFiles = versions.versions[keepVersion].files + versionFiles.forEach((file) => keepFiles.add(file.name)) + } + + // 删除所有旧版本文件 + for (const oldVersion of oldVersions) { + const oldFiles = versions.versions[oldVersion].files + for (const file of oldFiles) { + try { + if (file.uploaded) { + await env.R2_BUCKET.delete(file.name) + await addLog(env, 'INFO', `删除旧文件: ${file.name}`) + } + } catch (error) { + await addLog(env, 'ERROR', `删除旧文件失败: ${file.name}`, error.message) + } + } + delete versions.versions[oldVersion] + } + + // 清理可能遗留的旧文件 + for (const file of allFiles) { + if (!keepFiles.has(file.name)) { + try { + await env.R2_BUCKET.delete(file.name) + await addLog(env, 'INFO', `删除遗留文件: ${file.name}`) + } catch (error) { + await addLog(env, 'ERROR', `删除遗留文件失败: ${file.name}`, error.message) + } + } + } + + // 保存更新后的版本数据库 + await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(versions, null, 2)) + } + } else { + await addLog(env, 'INFO', '所有文件完整性检查通过,无需更新') + } + + // 合并 Mac yml 文件 + await mergeMacYmlFiles(env) + + return hasUpdates ? cacheData : null } catch (error) { - await addLog(env, 'ERROR', '检查新版本失败', error.message); - throw error; + await addLog(env, 'ERROR', '检查新版本失败', error.message) + throw error } } // 新增:获取 R2 桶中的所有文件列表 async function listAllFiles(env) { - const files = []; - let cursor; + const files = [] + let cursor do { - const listed = await env.R2_BUCKET.list({ cursor, include: ['customMetadata'] }); - files.push(...listed.objects); - cursor = listed.cursor; - } while (cursor); + const listed = await env.R2_BUCKET.list({ cursor, include: ['customMetadata'] }) + files.push(...listed.objects) + cursor = listed.cursor + } while (cursor) - return files; -} + return files +} + +async function mergeMacYmlFiles(env) { + try { + const macX64Yml = await env.R2_BUCKET.get('latest-mac-x64.yml') + const macArm64Yml = await env.R2_BUCKET.get('latest-mac-arm64.yml') + + if (!macX64Yml || !macArm64Yml) { + return + } + + const x64Content = await macX64Yml.text() + const arm64Content = await macArm64Yml.text() + + // 使用正则表达式提取 files 部分 + const filesRegex = /files:\n( - url:[\s\S]+?)(?=\npath:)/ + const x64Files = x64Content.match(filesRegex)[1] + const arm64Files = arm64Content.match(filesRegex)[1] + + // 合并内容 + const mergedContent = arm64Content.replace(filesRegex, `files:\n${arm64Files}\n${x64Files}`) + + // 保存合并后的文件 + await env.R2_BUCKET.put('latest-mac.yml', mergedContent, { + httpMetadata: { contentType: 'application/x-yaml' } + }) + + await addLog(env, 'INFO', 'Mac yml 文件合并成功') + } catch (error) { + await addLog(env, 'ERROR', 'Mac yml 文件合并失败', error.message) + } +} diff --git a/scripts/removeLocales.js b/scripts/removeLocales.js new file mode 100644 index 00000000..1dc0b3cc --- /dev/null +++ b/scripts/removeLocales.js @@ -0,0 +1,58 @@ +const fs = require('fs') +const path = require('path') + +exports.default = async function (context) { + const platform = context.packager.platform.name + + // 根据平台确定 locales 目录位置 + let resourceDirs = [] + if (platform === 'mac') { + // macOS 的语言文件位置 + resourceDirs = [ + path.join(context.appOutDir, 'Cherry Studio.app', 'Contents', 'Resources'), + path.join( + context.appOutDir, + 'Cherry Studio.app', + 'Contents', + 'Frameworks', + 'Electron Framework.framework', + 'Resources' + ) + ] + } else { + // Windows 和 Linux 的语言文件位置 + resourceDirs = [path.join(context.appOutDir, 'locales')] + } + + // 处理每个资源目录 + for (const resourceDir of resourceDirs) { + if (!fs.existsSync(resourceDir)) { + console.log(`Resource directory not found: ${resourceDir}, skipping...`) + continue + } + + // 读取所有文件和目录 + const items = fs.readdirSync(resourceDir) + + // 遍历并删除不需要的语言文件 + for (const item of items) { + if (platform === 'mac') { + // 在 macOS 上检查 .lproj 目录 + if (item.endsWith('.lproj') && !item.match(/^(en|zh|ru)/)) { + const dirPath = path.join(resourceDir, item) + fs.rmSync(dirPath, { recursive: true, force: true }) + console.log(`Removed locale directory: ${item} from ${resourceDir}`) + } + } else { + // 其他平台处理 .pak 文件 + if (!item.match(/^(en|zh|ru)/)) { + const filePath = path.join(resourceDir, item) + fs.unlinkSync(filePath) + console.log(`Removed locale file: ${item} from ${resourceDir}`) + } + } + } + } + + console.log('Locale cleanup completed!') +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 90303677..cebbd766 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -11,6 +11,7 @@ import BackupManager from './services/BackupManager' import { configManager } from './services/ConfigManager' import { ExportService } from './services/ExportService' import FileStorage from './services/FileStorage' +import KnowledgeService from './services/KnowledgeService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import { windowService } from './services/WindowService' import { compress, decompress } from './utils/zip' @@ -100,6 +101,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // file ipcMain.handle('file:open', fileManager.open) + ipcMain.handle('file:openPath', fileManager.openPath) ipcMain.handle('file:save', fileManager.save) ipcMain.handle('file:select', fileManager.selectFile) ipcMain.handle('file:upload', fileManager.uploadFile) @@ -144,4 +146,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { registerShortcuts(mainWindow) } }) + + // knowledge base + ipcMain.handle('knowledge-base:create', KnowledgeService.create) + ipcMain.handle('knowledge-base:reset', KnowledgeService.reset) + ipcMain.handle('knowledge-base:delete', KnowledgeService.delete) + ipcMain.handle('knowledge-base:add', KnowledgeService.add) + ipcMain.handle('knowledge-base:remove', KnowledgeService.remove) + ipcMain.handle('knowledge-base:search', KnowledgeService.search) } diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 887a351d..dff78863 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -8,7 +8,8 @@ import { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, - SaveDialogReturnValue + SaveDialogReturnValue, + shell } from 'electron' import logger from 'electron-log' import * as fs from 'fs' @@ -55,6 +56,8 @@ class FileStorage { const storedFilePath = path.join(this.storageDir, file) const storedStats = fs.statSync(storedFilePath) + console.debug('storedFilePath', storedFilePath) + if (storedStats.size === fileSize) { const [originalHash, storedHash] = await Promise.all([ this.getFileHash(filePath), @@ -298,6 +301,10 @@ class FileStorage { } } + public openPath = async (_: Electron.IpcMainInvokeEvent, path: string): Promise => { + shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err)) + } + public save = async ( _: Electron.IpcMainInvokeEvent, fileName: string, diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts new file mode 100644 index 00000000..5a05acd5 --- /dev/null +++ b/src/main/services/KnowledgeService.ts @@ -0,0 +1,140 @@ +import * as fs from 'node:fs' +import path from 'node:path' + +import { LocalPathLoader, RAGApplication, RAGApplicationBuilder, TextLoader } from '@llm-tools/embedjs' +import { AddLoaderReturn, ExtractChunkData } from '@llm-tools/embedjs-interfaces' +import { LibSqlDb } from '@llm-tools/embedjs-libsql' +import { MarkdownLoader } from '@llm-tools/embedjs-loader-markdown' +import { DocxLoader, ExcelLoader, PptLoader } from '@llm-tools/embedjs-loader-msoffice' +import { PdfLoader } from '@llm-tools/embedjs-loader-pdf' +import { SitemapLoader } from '@llm-tools/embedjs-loader-sitemap' +import { WebLoader } from '@llm-tools/embedjs-loader-web' +import { OpenAiEmbeddings } from '@llm-tools/embedjs-openai' +import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types' +import { app } from 'electron' + +class KnowledgeService { + private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase') + + constructor() { + this.initStorageDir() + } + + private initStorageDir = (): void => { + if (!fs.existsSync(this.storageDir)) { + fs.mkdirSync(this.storageDir, { recursive: true }) + } + } + + private getRagApplication = async ({ id, model, apiKey, baseURL }: KnowledgeBaseParams): Promise => { + return new RAGApplicationBuilder() + .setModel('NO_MODEL') + .setEmbeddingModel( + new OpenAiEmbeddings({ + model, + apiKey, + configuration: { baseURL }, + dimensions: 1024, + batchSize: 10 + }) + ) + .setVectorDatabase(new LibSqlDb({ path: path.join(this.storageDir, id) })) + .build() + } + + public create = async ( + _: Electron.IpcMainInvokeEvent, + { id, model, apiKey, baseURL }: KnowledgeBaseParams + ): Promise => { + this.getRagApplication({ id, model, apiKey, baseURL }) + } + + public reset = async (_: Electron.IpcMainInvokeEvent, { base }: { base: KnowledgeBaseParams }): Promise => { + const ragApplication = await this.getRagApplication(base) + await ragApplication.reset() + } + + public delete = async (_: Electron.IpcMainInvokeEvent, id: string): Promise => { + const dbPath = path.join(this.storageDir, id) + if (fs.existsSync(dbPath)) { + fs.rmSync(dbPath, { recursive: true }) + } + } + + public add = async ( + _: Electron.IpcMainInvokeEvent, + { base, item, forceReload = false }: { base: KnowledgeBaseParams; item: KnowledgeItem; forceReload: boolean } + ): Promise => { + const ragApplication = await this.getRagApplication(base) + + if (item.type === 'directory') { + const directory = item.content as string + return await ragApplication.addLoader(new LocalPathLoader({ path: directory }), forceReload) + } + + if (item.type === 'url') { + const content = item.content as string + if (content.startsWith('http')) { + return await ragApplication.addLoader(new WebLoader({ urlOrContent: content }), forceReload) + } + } + + if (item.type === 'sitemap') { + const content = item.content as string + return await ragApplication.addLoader(new SitemapLoader({ url: content }), forceReload) + } + + if (item.type === 'note') { + const content = item.content as string + return await ragApplication.addLoader(new TextLoader({ text: content }), forceReload) + } + + if (item.type === 'file') { + const file = item.content as FileType + + if (file.ext === '.pdf') { + return await ragApplication.addLoader(new PdfLoader({ filePathOrUrl: file.path }) as any, forceReload) + } + + if (file.ext === '.docx') { + return await ragApplication.addLoader(new DocxLoader({ filePathOrUrl: file.path }) as any, forceReload) + } + + if (file.ext === '.pptx') { + return await ragApplication.addLoader(new PptLoader({ filePathOrUrl: file.path }) as any, forceReload) + } + + if (file.ext === '.xlsx') { + return await ragApplication.addLoader(new ExcelLoader({ filePathOrUrl: file.path }) as any, forceReload) + } + + if (['.md', '.mdx'].includes(file.ext)) { + return await ragApplication.addLoader(new MarkdownLoader({ filePathOrUrl: file.path }) as any, forceReload) + } + + const fileContent = fs.readFileSync(file.path, 'utf-8') + + return await ragApplication.addLoader(new TextLoader({ text: fileContent }), forceReload) + } + + return { entriesAdded: 0, uniqueId: '', loaderType: '' } + } + + public remove = async ( + _: Electron.IpcMainInvokeEvent, + { uniqueId, base }: { uniqueId: string; base: KnowledgeBaseParams } + ): Promise => { + const ragApplication = await this.getRagApplication(base) + await ragApplication.deleteLoader(uniqueId) + } + + public search = async ( + _: Electron.IpcMainInvokeEvent, + { search, base }: { search: string; base: KnowledgeBaseParams } + ): Promise => { + const ragApplication = await this.getRagApplication(base) + return await ragApplication.search(search) + } +} + +export default new KnowledgeService() diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 11c13010..ada8b2ad 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -1,6 +1,7 @@ import { is } from '@electron-toolkit/utils' import { isLinux, isWin } from '@main/constant' import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron' +import Logger from 'electron-log' import windowStateKeeper from 'electron-window-state' import { join } from 'path' @@ -123,12 +124,25 @@ export class WindowService { private setupWebContentsHandlers(mainWindow: BrowserWindow) { mainWindow.webContents.on('will-navigate', (event, url) => { + if (url.includes('localhost:5173')) { + return + } + event.preventDefault() shell.openExternal(url) }) mainWindow.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url) + const { url } = details + + if (url.includes('http://file/')) { + const fileUrl = url.replace('http://file/', '') + const filePath = decodeURIComponent(fileUrl) + shell.openPath(filePath).catch((err) => Logger.error('Failed to open file:', err)) + } else { + shell.openExternal(details.url) + } + return { action: 'deny' } }) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 3846ac84..e9c24b41 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,7 +1,8 @@ import { ElectronAPI } from '@electron-toolkit/preload' +import { AddLoaderReturn, ExtractChunkData } from '@llm-tools/embedjs-interfaces' import { FileType } from '@renderer/types' import { WebDavConfig } from '@renderer/types' -import { AppInfo, LanguageVarious } from '@renderer/types' +import { AppInfo, KnowledgeBaseParams, KnowledgeItem, LanguageVarious } from '@renderer/types' import type { OpenDialogOptions } from 'electron' import type { UpdateInfo } from 'electron-updater' import { Readable } from 'stream' @@ -41,6 +42,7 @@ declare global { create: (fileName: string) => Promise write: (filePath: string, data: Uint8Array | string) => Promise open: (options?: OpenDialogOptions) => Promise<{ fileName: string; filePath: string; content: Buffer } | null> + openPath: (path: string) => Promise save: ( path: string, content: string | NodeJS.ArrayBufferView, @@ -58,6 +60,22 @@ declare global { shortcuts: { update: (shortcuts: Shortcut[]) => Promise } + knowledgeBase: { + create: ({ id, model, apiKey, baseURL }: KnowledgeBaseParams) => Promise + reset: ({ base }: { base: KnowledgeBaseParams }) => Promise + delete: (id: string) => Promise + add: ({ + base, + item, + forceReload = false + }: { + base: KnowledgeBaseParams + item: KnowledgeItem + forceReload?: boolean + }) => Promise + remove: ({ uniqueId, base }: { uniqueId: string; base: KnowledgeBaseParams }) => Promise + search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) => Promise + } } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index c625a3ac..2e1b8e99 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,5 @@ import { electronAPI } from '@electron-toolkit/preload' -import { Shortcut, WebDavConfig } from '@types' +import { KnowledgeBaseParams, KnowledgeItem, Shortcut, WebDavConfig } from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron' // Custom APIs for renderer @@ -36,6 +36,7 @@ const api = { create: (fileName: string) => ipcRenderer.invoke('file:create', fileName), write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke('file:write', filePath, data), open: (options?: { decompress: boolean }) => ipcRenderer.invoke('file:open', options), + openPath: (path: string) => ipcRenderer.invoke('file:openPath', path), save: (path: string, content: string, options?: { compress: boolean }) => ipcRenderer.invoke('file:save', path, content, options), selectFolder: () => ipcRenderer.invoke('file:selectFolder'), @@ -50,6 +51,25 @@ const api = { openPath: (path: string) => ipcRenderer.invoke('open:path', path), shortcuts: { update: (shortcuts: Shortcut[]) => ipcRenderer.invoke('shortcuts:update', shortcuts) + }, + knowledgeBase: { + create: ({ id, model, apiKey, baseURL }: KnowledgeBaseParams) => + ipcRenderer.invoke('knowledge-base:create', { id, model, apiKey, baseURL }), + reset: ({ base }: { base: KnowledgeBaseParams }) => ipcRenderer.invoke('knowledge-base:reset', { base }), + delete: (id: string) => ipcRenderer.invoke('knowledge-base:delete', id), + add: ({ + base, + item, + forceReload = false + }: { + base: KnowledgeBaseParams + item: KnowledgeItem + forceReload?: boolean + }) => ipcRenderer.invoke('knowledge-base:add', { base, item, forceReload }), + remove: ({ uniqueId, base }: { uniqueId: string; base: KnowledgeBaseParams }) => + ipcRenderer.invoke('knowledge-base:remove', { uniqueId, base }), + search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) => + ipcRenderer.invoke('knowledge-base:search', { search, base }) } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6ac062ec..11f15171 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -14,6 +14,7 @@ import AgentsPage from './pages/agents/AgentsPage' import AppsPage from './pages/apps/AppsPage' import FilesPage from './pages/files/FilesPage' import HomePage from './pages/home/HomePage' +import KnowledgePage from './pages/knowledge/KnowledgePage' import PaintingsPage from './pages/paintings/PaintingsPage' import SettingsPage from './pages/settings/SettingsPage' import TranslatePage from './pages/translate/TranslatePage' @@ -30,10 +31,11 @@ function App(): JSX.Element { } /> - } /> } /> } /> } /> + } /> + } /> } /> } /> diff --git a/src/renderer/src/assets/images/logo.png b/src/renderer/src/assets/images/logo.png index 0cae841a..ab6c968a 100644 Binary files a/src/renderer/src/assets/images/logo.png and b/src/renderer/src/assets/images/logo.png differ diff --git a/src/renderer/src/assets/images/logo/cherry-hr.svg b/src/renderer/src/assets/images/logo/cherry-hr.svg deleted file mode 100644 index 4dad25f2..00000000 --- a/src/renderer/src/assets/images/logo/cherry-hr.svg +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/renderer/src/assets/images/logo/cherry-text.svg b/src/renderer/src/assets/images/logo/cherry-text.svg deleted file mode 100644 index 80939f5d..00000000 --- a/src/renderer/src/assets/images/logo/cherry-text.svg +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/renderer/src/assets/images/models/bigcode.webp b/src/renderer/src/assets/images/models/bigcode.webp new file mode 100644 index 00000000..1f761feb Binary files /dev/null and b/src/renderer/src/assets/images/models/bigcode.webp differ diff --git a/src/renderer/src/assets/images/models/bigcode_dark.webp b/src/renderer/src/assets/images/models/bigcode_dark.webp new file mode 100644 index 00000000..9ca73e59 Binary files /dev/null and b/src/renderer/src/assets/images/models/bigcode_dark.webp differ diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 38559722..2a76db2c 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -42,6 +42,7 @@ --color-active: rgba(55, 55, 55, 1); --color-frame-border: #333; --color-group-background: var(--color-background-soft); + --color-reference-background: #0b0e12; --navbar-background-mac: rgba(30, 30, 30, 0.6); --navbar-background: rgba(30, 30, 30); @@ -99,6 +100,7 @@ body[theme-mode='light'] { --color-active: var(--color-white-soft); --color-frame-border: #ddd; --color-group-background: var(--color-white); + --color-reference-background: #f1f7ff; --navbar-background-mac: rgba(255, 255, 255, 0.6); --navbar-background: rgba(255, 255, 255); diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index 860b2462..4fc4761c 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -229,11 +229,24 @@ .footnotes { margin-top: 1em; + margin-bottom: 1em; padding-top: 1em; - border-top: 1px solid var(--color-border); + + background-color: var(--color-reference-background); + border-radius: 8px; + padding: 8px 12px; + + h4 { + margin-bottom: 5px; + font-size: 12px; + } ol { padding-left: 1em; + margin: 0; + li:last-child { + margin-bottom: 0; + } } li { diff --git a/src/renderer/src/components/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/components/AssistantSettings/AssistantModelSettings.tsx index a073aae7..90cfd212 100644 --- a/src/renderer/src/components/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/components/AssistantSettings/AssistantModelSettings.tsx @@ -241,7 +241,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA )} - + { diff --git a/src/renderer/src/components/ListItem/index.tsx b/src/renderer/src/components/ListItem/index.tsx new file mode 100644 index 00000000..e4e31a24 --- /dev/null +++ b/src/renderer/src/components/ListItem/index.tsx @@ -0,0 +1,52 @@ +import { ReactNode } from 'react' +import styled from 'styled-components' + +interface ListItemProps { + active?: boolean + icon?: ReactNode + title: string + onClick?: () => void +} + +const ListItem = ({ active, icon, title, onClick }: ListItemProps) => { + return ( + + + {icon && {icon}} + {title} + + + ) +} + +const ListItemContainer = styled.div` + padding: 7px 12px; + border-radius: 16px; + font-size: 13px; + display: flex; + flex-direction: column; + justify-content: space-between; + position: relative; + font-family: Ubuntu; + cursor: pointer; + border: 1px solid transparent; + + &:hover { + background-color: var(--color-background-soft); + } + + &.active { + background-color: var(--color-background-soft); + border: 1px solid var(--color-border-soft); + } +` + +const ListItemContent = styled.div` + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 13px; +` + +export default ListItem diff --git a/src/renderer/src/components/Popups/SelectModelPopup.tsx b/src/renderer/src/components/Popups/SelectModelPopup.tsx index beb2fbfa..bc444fba 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup.tsx @@ -1,7 +1,7 @@ import { PushpinOutlined, SearchOutlined } from '@ant-design/icons' import VisionIcon from '@renderer/components/Icons/VisionIcon' import { TopView } from '@renderer/components/TopView' -import { getModelLogo, isVisionModel, isWebSearchModel } from '@renderer/config/models' +import { getModelLogo, isEmbeddingModel, isVisionModel, isWebSearchModel } from '@renderer/config/models' import db from '@renderer/databases' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' @@ -66,6 +66,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { .filter((p) => p.models && p.models.length > 0) .map((p) => { const filteredModels = sortBy(p.models, ['group', 'name']) + .filter((m) => !isEmbeddingModel(m)) .filter((m) => [m.name + m.provider + t('provider.' + p.id)].join('').toLowerCase().includes(searchText.toLowerCase()) ) @@ -142,7 +143,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { if (pinnedItems.length > 0) { filteredItems.unshift({ key: 'pinned', - label: t('model.pinned'), + label: t('models.pinned'), type: 'group', children: pinnedItems } as MenuItem) @@ -188,7 +189,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { } ref={inputRef} - placeholder={t('model.search')} + placeholder={t('models.search')} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx index 8b2a7674..f181505a 100644 --- a/src/renderer/src/components/app/Sidebar.tsx +++ b/src/renderer/src/components/app/Sidebar.tsx @@ -1,4 +1,4 @@ -import { FolderOutlined, PictureOutlined, TranslationOutlined } from '@ant-design/icons' +import { FileSearchOutlined, FolderOutlined, PictureOutlined, TranslationOutlined } from '@ant-design/icons' import { isMac } from '@renderer/config/constant' import { isLocalAi, UserAvatar } from '@renderer/config/env' import { useTheme } from '@renderer/context/ThemeProvider' @@ -88,6 +88,13 @@ const Sidebar: FC = () => { )} + + to('/knowledge')}> + + + + + {showFilesIcon && ( to('/files')}> diff --git a/src/renderer/src/config/minapps.ts b/src/renderer/src/config/minapps.ts index 223c577a..c20253ec 100644 --- a/src/renderer/src/config/minapps.ts +++ b/src/renderer/src/config/minapps.ts @@ -221,7 +221,8 @@ const _apps: MinAppType[] = [ id: 'thinkany', name: 'ThinkAny', logo: ThinkAnyLogo, - url: 'https://thinkany.ai/' + url: 'https://thinkany.ai/', + bodered: true } ] diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 7adef97d..f00e4683 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -10,8 +10,8 @@ import AisingaporeModelLogo from '@renderer/assets/images/models/aisingapore.png import AisingaporeModelLogoDark from '@renderer/assets/images/models/aisingapore_dark.png' import BaichuanModelLogo from '@renderer/assets/images/models/baichuan.png' import BaichuanModelLogoDark from '@renderer/assets/images/models/baichuan_dark.png' -import BigcodeModelLogo from '@renderer/assets/images/models/bigcode.png' -import BigcodeModelLogoDark from '@renderer/assets/images/models/bigcode_dark.png' +import BigcodeModelLogo from '@renderer/assets/images/models/bigcode.webp' +import BigcodeModelLogoDark from '@renderer/assets/images/models/bigcode_dark.webp' import ChatGLMModelLogo from '@renderer/assets/images/models/chatglm.png' import ChatGLMModelLogoDark from '@renderer/assets/images/models/chatglm_dark.png' import ChatGptModelLogo from '@renderer/assets/images/models/chatgpt.jpeg' @@ -151,9 +151,9 @@ export const VISION_REGEX = new RegExp( 'i' ) -const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview/i -const EMBEDDING_REGEX = /(?:^text-|embed|rerank|davinci|babbage|bge-|base|retrieval|uae-)/i -const NOT_SUPPORTED_REGEX = /(?:^text-|embed|tts|rerank|whisper|speech|davinci|babbage|bge-|base|retrieval|uae-)/i +export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview/i +export const EMBEDDING_REGEX = /(?:^text-|embed|rerank|davinci|babbage|bge-|base|retrieval|uae-|gte-)/i +export const NOT_SUPPORTED_REGEX = /(?:^tts|rerank|whisper|speech)/i export function getModelLogo(modelId: string) { const isLight = true @@ -1047,18 +1047,38 @@ export function isTextToImageModel(model: Model): boolean { } export function isEmbeddingModel(model: Model): boolean { - return EMBEDDING_REGEX.test(model.id) + if (!model) { + return false + } + + if (['anthropic'].includes(model?.provider)) { + return false + } + + return EMBEDDING_REGEX.test(model.id) || model.type?.includes('embedding') || false } export function isVisionModel(model: Model): boolean { + if (!model) { + return false + } + return VISION_REGEX.test(model.id) || model.type?.includes('vision') || false } export function isSupportedModel(model: OpenAI.Models.Model): boolean { + if (!model) { + return false + } + return !NOT_SUPPORTED_REGEX.test(model.id) } export function isWebSearchModel(model: Model): boolean { + if (!model) { + return false + } + const provider = getProviderByModel(model) if (!provider) { diff --git a/src/renderer/src/config/prompts.ts b/src/renderer/src/config/prompts.ts index 05856be2..2ccd4f73 100644 --- a/src/renderer/src/config/prompts.ts +++ b/src/renderer/src/config/prompts.ts @@ -49,3 +49,30 @@ export const SUMMARIZE_PROMPT = export const TRANSLATE_PROMPT = 'You are a translation expert. Translate from input language to {{target_language}}, provide the translation result directly without any explanation and keep original format. Do not translate if the target language is the same as the source language.' + +export const REFERENCE_PROMPT = `请根据参考资料回答问题,并使用脚注格式引用数据来源。参考资料可能和问题无关,请忽略无关的参考资料。 + +## 脚注格式: + +1. **脚注标记**:在正文中使用 [^数字] 的形式标记脚注,例如 [^1]。 +2. **脚注内容**:在文档末尾使用 [^数字]: 脚注内容 的形式定义脚注的具体内容。 + +## 脚注示例和要求: + +1. type 为 file 时:[^1]: [__name__](http://file/__url__) +2. type 为 directory 时:[^1]: [__name__](http://file/__url__) +3. type 为 url,sitemap 时:[^1]: [__name__](__url__) +4. type 为 note 时:[^1]: __note__ + +__url__ 替换成参考资料的 url +__name__ 请根据参考资料的 url 进行解析和替换 +__note__ 请根据参考资料的 content 进行总结和替换 + +## 我的问题是: + +{question} + +## 参考资料: + +{references} +` diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 73c9635b..2600e902 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -355,11 +355,11 @@ export const PROVIDER_CONFIG = { }, aihubmix: { api: { - url: 'https://aihubmix.com' + url: 'https://aihubmix.com?aff=SJyh' }, websites: { official: 'https://aihubmix.com/', - apiKey: 'https://aihubmix.com/token', + apiKey: 'https://aihubmix.com?aff=SJyh', docs: 'https://doc.aihubmix.com/', models: 'https://aihubmix.com/models' } diff --git a/src/renderer/src/context/AntdProvider.tsx b/src/renderer/src/context/AntdProvider.tsx index e571ba0d..20cc5b24 100644 --- a/src/renderer/src/context/AntdProvider.tsx +++ b/src/renderer/src/context/AntdProvider.tsx @@ -31,6 +31,13 @@ const AntdProvider: FC = ({ children }) => { Menu: { activeBarBorderWidth: 0, darkItemBg: 'transparent' + }, + Button: { + boxShadow: 'none', + boxShadowSecondary: 'none', + defaultShadow: 'none', + dangerShadow: 'none', + primaryShadow: 'none' } }, token: { diff --git a/src/renderer/src/databases/index.ts b/src/renderer/src/databases/index.ts index 1714d19b..cf90000f 100644 --- a/src/renderer/src/databases/index.ts +++ b/src/renderer/src/databases/index.ts @@ -1,4 +1,4 @@ -import { FileType, Topic } from '@renderer/types' +import { FileType, KnowledgeItem, Topic } from '@renderer/types' import { Dexie, type EntityTable } from 'dexie' // Database declaration (move this to its own module also) @@ -6,6 +6,7 @@ export const db = new Dexie('CherryStudio') as Dexie & { files: EntityTable topics: EntityTable, 'id'> settings: EntityTable<{ id: string; value: any }, 'id'> + knowledge_notes: EntityTable } db.version(1).stores({ @@ -18,4 +19,11 @@ db.version(2).stores({ settings: '&id, value' }) +db.version(3).stores({ + files: 'id, name, origin_name, path, size, ext, type, created_at, count', + topics: '&id, messages', + settings: '&id, value', + knowledge_notes: '&id, baseId, type, content, created_at, updated_at' +}) + export default db diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 3a803f0a..c074db1b 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -73,4 +73,8 @@ export function useAppInit() { dispatch(setFilesPath(info.filesPath)) }) }, [dispatch]) + + useEffect(() => { + import('@renderer/queue/KnowledgeQueue') + }, []) } diff --git a/src/renderer/src/hooks/useKnowledge.ts b/src/renderer/src/hooks/useKnowledge.ts new file mode 100644 index 00000000..3c030cec --- /dev/null +++ b/src/renderer/src/hooks/useKnowledge.ts @@ -0,0 +1,279 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { db } from '@renderer/databases/index' +import KnowledgeQueue from '@renderer/queue/KnowledgeQueue' +import FileManager from '@renderer/services/FileManager' +import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService' +import { RootState } from '@renderer/store' +import { + addBase, + addFiles as addFilesAction, + addItem, + clearAllProcessing, + clearCompletedProcessing, + deleteBase, + removeItem as removeItemAction, + renameBase, + updateBase, + updateBases, + updateItemProcessingStatus, + updateNotes +} from '@renderer/store/knowledge' +import { FileType, KnowledgeBase, ProcessingStatus } from '@renderer/types' +import { KnowledgeItem } from '@renderer/types' +import { runAsyncFunction } from '@renderer/utils' +import { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { v4 as uuidv4 } from 'uuid' + +export const useKnowledge = (baseId: string) => { + const dispatch = useDispatch() + const base = useSelector((state: RootState) => state.knowledge.bases.find((b) => b.id === baseId)) + + // 重命名知识库 + const renameKnowledgeBase = (name: string) => { + dispatch(renameBase({ baseId, name })) + } + + // 更新知识库 + const updateKnowledgeBase = (base: KnowledgeBase) => { + dispatch(updateBase(base)) + } + + // 批量添加文件 + const addFiles = (files: FileType[]) => { + const filesItems: KnowledgeItem[] = files.map((file) => ({ + id: uuidv4(), + type: 'file' as const, + content: file, + created_at: Date.now(), + updated_at: Date.now(), + processingStatus: 'pending', + processingProgress: 0, + processingError: '', + retryCount: 0 + })) + dispatch(addFilesAction({ baseId, items: filesItems })) + setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + } + + // 添加URL + const addUrl = (url: string) => { + const newUrlItem: KnowledgeItem = { + id: uuidv4(), + type: 'url' as const, + content: url, + created_at: Date.now(), + updated_at: Date.now(), + processingStatus: 'pending', + processingProgress: 0, + processingError: '', + retryCount: 0 + } + dispatch(addItem({ baseId, item: newUrlItem })) + setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + } + + // 添加笔记 + const addNote = async (content: string) => { + const noteId = uuidv4() + const note: KnowledgeItem = { + id: noteId, + type: 'note', + content, + created_at: Date.now(), + updated_at: Date.now() + } + + // 存储完整笔记到数据库 + await db.knowledge_notes.add(note) + + // 在 store 中只存储引用 + const noteRef: KnowledgeItem = { + id: noteId, + baseId, + type: 'note', + content: '', // store中不需要存储实际内容 + created_at: Date.now(), + updated_at: Date.now(), + processingStatus: 'pending', + processingProgress: 0, + processingError: '', + retryCount: 0 + } + + dispatch(updateNotes({ baseId, item: noteRef })) + setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + } + + // 更新笔记内容 + const updateNoteContent = async (noteId: string, content: string) => { + const note = await db.knowledge_notes.get(noteId) + if (note) { + const updatedNote = { + ...note, + content, + updated_at: Date.now() + } + await db.knowledge_notes.put(updatedNote) + dispatch(updateNotes({ baseId, item: updatedNote })) + } + setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + } + + // 获取笔记内容 + const getNoteContent = async (noteId: string) => { + return await db.knowledge_notes.get(noteId) + } + + // 移除项目 + const removeItem = async (item: KnowledgeItem) => { + dispatch(removeItemAction({ baseId, item })) + if (base) { + if (item?.uniqueId) { + await window.api.knowledgeBase.remove({ uniqueId: item.uniqueId, base: getKnowledgeBaseParams(base) }) + } + if (item.type === 'file' && typeof item.content === 'object') { + await FileManager.deleteFile(item.content.id) + } + } + } + + // 更新处理状态 + const updateItemStatus = (itemId: string, status: ProcessingStatus, progress?: number, error?: string) => { + dispatch( + updateItemProcessingStatus({ + baseId, + itemId, + status, + progress, + error + }) + ) + } + + // 获取特定项目的处理状态 + const getProcessingStatus = (itemId: string) => { + return base?.items.find((item) => item.id === itemId)?.processingStatus + } + + // 获取特定类型的所有处理项 + const getProcessingItemsByType = (type: 'file' | 'url' | 'note') => { + return base?.items.filter((item) => item.type === type && item.processingStatus !== undefined) || [] + } + + // 清除已完成的项目 + const clearCompleted = () => { + dispatch(clearCompletedProcessing({ baseId })) + } + + // 清除所有处理状态 + const clearAll = () => { + dispatch(clearAllProcessing({ baseId })) + } + + // 添加 Sitemap + const addSitemap = (url: string) => { + const newSitemapItem: KnowledgeItem = { + id: uuidv4(), + type: 'sitemap' as const, + content: url, + created_at: Date.now(), + updated_at: Date.now(), + processingStatus: 'pending', + processingProgress: 0, + processingError: '', + retryCount: 0 + } + dispatch(addItem({ baseId, item: newSitemapItem })) + setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + } + + // Add directory support + const addDirectory = (path: string) => { + const newDirectoryItem: KnowledgeItem = { + id: uuidv4(), + type: 'directory', + content: path, + created_at: Date.now(), + updated_at: Date.now(), + processingStatus: 'pending', + processingProgress: 0, + processingError: '', + retryCount: 0 + } + dispatch(addItem({ baseId, item: newDirectoryItem })) + setTimeout(() => KnowledgeQueue.checkAllBases(), 0) + } + + const fileItems = base?.items.filter((item) => item.type === 'file') || [] + const directoryItems = base?.items.filter((item) => item.type === 'directory') || [] + const urlItems = base?.items.filter((item) => item.type === 'url') || [] + const sitemapItems = base?.items.filter((item) => item.type === 'sitemap') || [] + const [noteItems, setNoteItems] = useState([]) + + useEffect(() => { + const notes = base?.items.filter((item) => item.type === 'note') || [] + runAsyncFunction(async () => { + const newNoteItems = await Promise.all( + notes.map(async (item) => { + const note = await db.knowledge_notes.get(item.id) + return { ...item, content: note?.content || '' } + }) + ) + setNoteItems(newNoteItems.filter((note) => note !== undefined) as KnowledgeItem[]) + }) + }, [base?.items]) + + return { + base, + fileItems, + urlItems, + sitemapItems, + noteItems, + renameKnowledgeBase, + updateKnowledgeBase, + addFiles, + addUrl, + addSitemap, + addNote, + updateNoteContent, + getNoteContent, + updateItemStatus, + getProcessingStatus, + getProcessingItemsByType, + clearCompleted, + clearAll, + removeItem, + directoryItems, + addDirectory + } +} + +export const useKnowledgeBases = () => { + const dispatch = useDispatch() + const bases = useSelector((state: RootState) => state.knowledge.bases) + + const addKnowledgeBase = (base: KnowledgeBase) => { + dispatch(addBase(base)) + } + + const renameKnowledgeBase = (baseId: string, name: string) => { + dispatch(renameBase({ baseId, name })) + } + + const deleteKnowledgeBase = (baseId: string) => { + dispatch(deleteBase({ baseId })) + } + + const updateKnowledgeBases = (bases: KnowledgeBase[]) => { + dispatch(updateBases(bases)) + } + + return { + bases, + addKnowledgeBase, + renameKnowledgeBase, + deleteKnowledgeBase, + updateKnowledgeBases + } +} diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index 14283c3b..a9f80147 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -37,8 +37,10 @@ export const useShortcut = ( const shortcutConfig = shortcuts.find((s) => s.key === shortcutKey) + console.log(shortcutConfig) + useHotkeys( - shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : '', + shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : 'none', (e) => { if (options.preventDefault) { e.preventDefault() @@ -49,7 +51,8 @@ export const useShortcut = ( }, { enableOnFormTags: options.enableOnFormTags, - description: options.description || shortcutConfig?.key + description: options.description || shortcutConfig?.key, + enabled: !!shortcutConfig?.enabled } ) } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 86c3079f..61bca99d 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -81,6 +81,7 @@ "input.translate": "Translate to English", "input.upload": "Upload image or document file", "input.web_search": "Enable web search", + "input.knowledge_base": "Knowledge Base", "message.new.branch": "New Branch", "message.new.branch.created": "New Branch Created", "message.regenerate.model": "Switch Model", @@ -252,16 +253,6 @@ "minapp": { "title": "MinApp" }, - "model": { - "pinned": "Pinned", - "search": "Search models...", - "stream_output": "Stream output", - "type": { - "select": "Select Model Types", - "text": "Text", - "vision": "Vision" - } - }, "ollama": { "keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.", "keep_alive_time.placeholder": "Minutes", @@ -393,7 +384,7 @@ "messages.input.paste_long_text_as_file": "Paste long text as file", "messages.input.send_shortcuts": "Send shortcuts", "messages.input.show_estimated_tokens": "Show estimated tokens", - "messages.metrics": "{{time_first_token_millsec}}ms to first token • {{token_speed}} tok/sec • ", + "messages.metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec", "messages.input.title": "Input Settings", "messages.markdown_rendering_input_message": "Markdown render input msg", "messages.math_engine": "Math render engine", @@ -525,7 +516,65 @@ }, "words": { "knowledgeGraph": "Knowledge Graph", - "visualization": "Visualization" + "visualization": "Visualization", + "show_window": "Show Window", + "quit": "Quit" + }, + "knowledge_base": { + "title": "Knowledge Base", + "search": "Search knowledge base", + "empty": "No knowledge base found", + "drag_file": "Drag file here", + "file_hint": "Support {{file_types}}", + "add": { + "title": "Add Knowledge Base" + }, + "notes": "Notes", + "notes_placeholder": "Enter additional information or context for this knowledge base...", + "delete": "Delete", + "rename": "Rename", + "urls": "URLs", + "add_url": "Add URL", + "url_placeholder": "Enter URL", + "invalid_url": "Invalid URL", + "add_file": "Add File", + "status": "Status", + "index_all": "Index All", + "index_started": "Indexing started", + "cancel_index": "Cancel Indexing", + "index_cancelled": "Indexing cancelled", + "status_pending": "Pending", + "status_processing": "Processing", + "status_completed": "Completed", + "status_failed": "Failed", + "url_added": "URL added", + "search_placeholder": "Enter text to search", + "add_note": "Add Note", + "no_bases": "No knowledge bases available", + "clear_selection": "Clear selection", + "delete_confirm": "Are you sure you want to delete this knowledge base?", + "sitemaps": "Websites", + "add_sitemap": "Website Map", + "sitemap_placeholder": "Enter Website Map URL", + "directories": "Directories", + "add_directory": "Add Directory", + "directory_placeholder": "Enter Directory Path" + }, + "models": { + "pinned": "Pinned", + "search": "Search models...", + "stream_output": "Stream output", + "type": { + "select": "Select Model Types", + "text": "Text", + "vision": "Vision", + "embedding": "Embedding" + }, + "all": "All", + "vision": "Vision", + "websearch": "WebSearch", + "free": "Free", + "embedding": "Embedding" } } } diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 1147ee4b..5fed5df3 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -81,6 +81,7 @@ "input.translate": "Перевести на английский", "input.upload": "Загрузить изображение или документ", "input.web_search": "Включить веб-поиск", + "input.knowledge_base": "База знаний", "message.new.branch": "Новая ветка", "message.new.branch.created": "Новая ветка создана", "message.regenerate.model": "Переключить модель", @@ -252,16 +253,6 @@ "minapp": { "title": "Встроенные приложения" }, - "model": { - "pinned": "Закреплено", - "search": "Поиск моделей...", - "stream_output": "Потоковый вывод", - "type": { - "select": "Выберите тип модели", - "text": "Текст", - "vision": "Изображение" - } - }, "ollama": { "keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.", "keep_alive_time.placeholder": "Минуты", @@ -393,6 +384,7 @@ "messages.input.paste_long_text_as_file": "Вставлять длинный текст как файл", "messages.input.send_shortcuts": "Горячие клавиши для отправки", "messages.input.show_estimated_tokens": "Показывать затраты токенов", + "messages.metrics": "{{time_first_token_millsec}}ms до первого токена | {{token_speed}} tok/sec", "messages.input.title": "Настройки ввода", "messages.markdown_rendering_input_message": "Отображение ввода в формате Markdown", "messages.math_engine": "Математический движок", @@ -524,7 +516,65 @@ }, "words": { "knowledgeGraph": "Граф знаний", - "visualization": "Визуализация" + "visualization": "Визуализация", + "show_window": "Показать окно", + "quit": "Выйти" + }, + "knowledge_base": { + "title": "База знаний", + "search": "Поиск в базе знаний", + "empty": "База знаний не найдена", + "drag_file": "Перетащите файл сюда", + "file_hint": "Поддерживаются {{file_types}}", + "add": { + "title": "Добавить базу знаний" + }, + "notes": "Заметки", + "notes_placeholder": "Введите дополнительную информацию или контекст для этой базы знаний...", + "delete": "Удалить", + "rename": "Переименовать", + "urls": "URL-адреса", + "add_url": "Добавить URL", + "url_placeholder": "Введите URL", + "invalid_url": "Неверный URL", + "add_file": "Добавить файл", + "status": "Статус", + "index_all": "Индексировать все", + "index_started": "Индексирование началось", + "cancel_index": "Отменить индексирование", + "index_cancelled": "Индексирование отменено", + "status_pending": "Ожидание", + "status_processing": "Обработка", + "status_completed": "Завершено", + "status_failed": "Ошибка", + "url_added": "URL добавлен", + "search_placeholder": "Введите текст для поиска", + "add_note": "Добавить запись", + "no_bases": "База знаний не найдена", + "clear_selection": "Очистить выбор", + "delete_confirm": "Вы уверены, что хотите удалить эту базу знаний?", + "sitemaps": "Сайты", + "add_sitemap": "Карта сайта", + "sitemap_placeholder": "Введите URL карты сайта", + "directories": "Директории", + "add_directory": "Добавить директорию", + "directory_placeholder": "Введите путь к директории" + }, + "models": { + "pinned": "Закреплено", + "search": "Поиск моделей...", + "stream_output": "Потоковый вывод", + "type": { + "select": "Выберите тип модели", + "text": "Текст", + "vision": "Изображение", + "embedding": "Встраиваемые" + }, + "all": "Все", + "vision": "Визуальные модели", + "websearch": "Веб-поисковые модели", + "free": "Бесплатные модели", + "embedding": "Встраиваемые модели" } } } diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 7de42e6d..3c2ef996 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -81,6 +81,7 @@ "input.translate": "翻译成英文", "input.upload": "上传图片或文档", "input.web_search": "开启网络搜索", + "input.knowledge_base": "知识库", "message.new.branch": "新分支", "message.new.branch.created": "新分支已创建", "message.regenerate.model": "切换模型", @@ -148,7 +149,8 @@ "warning": "警告", "you": "用户", "clear": "清除", - "add": "添加" + "add": "添加", + "footnotes": "引用内容" }, "error": { "backup.file_format": "备份文件格式错误", @@ -252,16 +254,6 @@ "minapp": { "title": "小程序" }, - "model": { - "pinned": "已固定", - "search": "搜索模型...", - "stream_output": "流式输出", - "type": { - "select": "选择模型类型", - "text": "文本", - "vision": "图像" - } - }, "ollama": { "keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)", "keep_alive_time.placeholder": "分钟", @@ -393,7 +385,7 @@ "messages.input.paste_long_text_as_file": "长文本粘贴为文件", "messages.input.send_shortcuts": "发送快捷键", "messages.input.show_estimated_tokens": "显示预估 Token 数", - "messages.metrics": "首字时延 {{time_first_token_millsec}}ms • 每秒 {{token_speed}} token • ", + "messages.metrics": "首字时延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens", "messages.input.title": "输入设置", "messages.markdown_rendering_input_message": "Markdown 渲染输入消息", "messages.math_engine": "数学公式引擎", @@ -513,7 +505,65 @@ }, "words": { "knowledgeGraph": "知识图谱", - "visualization": "可视化" + "visualization": "可视化", + "show_window": "显示窗口", + "quit": "退出" + }, + "knowledge_base": { + "title": "知识库", + "search": "搜索知识库", + "empty": "暂无知识库", + "drag_file": "拖拽文件到这里", + "file_hint": "支持 {{file_types}} 格式", + "add": { + "title": "添加知识库" + }, + "notes": "笔记", + "notes_placeholder": "输入此知识库的附加信息或上下文...", + "delete": "删除", + "rename": "重命名", + "urls": "网址", + "add_url": "添加网址", + "url_placeholder": "请输入网址", + "invalid_url": "无效的网址", + "add_file": "添加文件", + "status": "状态", + "index_all": "索引全部", + "index_started": "索引开始", + "cancel_index": "取消索引", + "index_cancelled": "索引已取消", + "status_pending": "等待中", + "status_processing": "处理中", + "status_completed": "已完成", + "status_failed": "失败", + "url_added": "网址已添加", + "search_placeholder": "输入查询内容", + "add_note": "添加笔记", + "no_bases": "暂无知识库", + "clear_selection": "清除选择", + "delete_confirm": "确定要删除此知识库吗?", + "sitemaps": "网站", + "add_sitemap": "站点地图", + "sitemap_placeholder": "请输入站点地图 URL", + "directories": "目录", + "add_directory": "添加目录", + "directory_placeholder": "请输入目录路径" + }, + "models": { + "pinned": "已固定", + "search": "搜索模型...", + "stream_output": "流式输出", + "type": { + "select": "选择模型类型", + "text": "文本", + "vision": "图像", + "embedding": "嵌入" + }, + "all": "全部", + "vision": "视觉模型", + "websearch": "网络搜索模型", + "free": "免费模型", + "embedding": "嵌入模型" } } } \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index bd04a4ed..76547d85 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -81,6 +81,7 @@ "input.translate": "翻譯成英文", "input.upload": "上傳圖片或文檔", "input.web_search": "開啟網路搜索", + "input.knowledge_base": "知識庫", "message.new.branch": "新分支", "message.new.branch.created": "新分支已建立", "message.regenerate.model": "切換模型", @@ -130,7 +131,6 @@ "download": "下載", "duplicate": "複製", "edit": "編輯", - "footnotes": "引用", "language": "語言", "model": "模型", "models": "模型", @@ -148,7 +148,8 @@ "warning": "警告", "you": "您", "clear": "清除", - "add": "添加" + "add": "添加", + "footnotes": "引用" }, "error": { "backup.file_format": "備份文件格式錯誤", @@ -252,16 +253,6 @@ "minapp": { "title": "小程序" }, - "model": { - "pinned": "已固定", - "search": "搜尋模型...", - "stream_output": "串流輸出", - "type": { - "select": "選擇模型類型", - "text": "文字", - "vision": "圖像" - } - }, "ollama": { "keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)。", "keep_alive_time.placeholder": "分鐘", @@ -393,6 +384,7 @@ "messages.input.paste_long_text_as_file": "將長文本貼上為檔案", "messages.input.send_shortcuts": "發送快捷鍵", "messages.input.show_estimated_tokens": "顯示預估 Token 數", + "messages.metrics": "首字時延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens", "messages.input.title": "輸入設定", "messages.math_engine": "Markdown 渲染輸入訊息", "messages.math_render_engine": "數學公式引擎", @@ -512,7 +504,65 @@ }, "words": { "knowledgeGraph": "知識圖譜", - "visualization": "可視化" + "visualization": "可視化", + "show_window": "顯示視窗", + "quit": "退出" + }, + "knowledge_base": { + "title": "知識庫", + "search": "搜尋知識庫", + "empty": "暫無知識庫", + "drag_file": "拖拽文件到這裡", + "file_hint": "支持 {{file_types}} 格式", + "add": { + "title": "添加知識庫" + }, + "notes": "筆記", + "notes_placeholder": "輸入此知識庫的附加資訊或上下文...", + "delete": "刪除", + "rename": "重命名", + "urls": "網址", + "add_url": "添加網址", + "url_placeholder": "請輸入網址", + "invalid_url": "無效的網址", + "add_file": "添加文件", + "status": "狀態", + "index_all": "索引全部", + "index_started": "索引開始", + "cancel_index": "取消索引", + "index_cancelled": "索引已取消", + "status_pending": "等待中", + "status_processing": "處理中", + "status_completed": "已完成", + "status_failed": "失敗", + "url_added": "網址已添加", + "search_placeholder": "輸入查詢內容", + "add_note": "添加筆記", + "no_bases": "暫無知識庫", + "clear_selection": "清除選擇", + "delete_confirm": "確定要刪除此知識庫嗎?", + "sitemaps": "網站", + "add_sitemap": "網站地圖", + "sitemap_placeholder": "請輸入網站地圖 URL", + "directories": "目錄", + "add_directory": "添加目錄", + "directory_placeholder": "請輸入目錄路徑" + }, + "models": { + "pinned": "已固定", + "search": "搜尋模型...", + "stream_output": "串流輸出", + "type": { + "select": "選擇模型類型", + "text": "文字", + "vision": "圖像", + "embedding": "嵌入" + }, + "all": "全部", + "vision": "視覺模型", + "websearch": "網路搜索模型", + "free": "免費模型", + "embedding": "嵌入模型" } } } diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx index 93b3dfe0..ac1418e9 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx @@ -11,9 +11,10 @@ interface Props { files: FileType[] setFiles: (files: FileType[]) => void ToolbarButton: any + disabled?: boolean } -const AttachmentButton: FC = ({ model, files, setFiles, ToolbarButton }) => { +const AttachmentButton: FC = ({ model, files, setFiles, ToolbarButton, disabled }) => { const { t } = useTranslation() const extensions = isVisionModel(model) ? [...imageExts, ...documentExts, ...textExts] @@ -37,7 +38,7 @@ const AttachmentButton: FC = ({ model, files, setFiles, ToolbarButton }) return ( - + diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 56a85255..4416a466 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -25,7 +25,7 @@ import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/Toke import { translateText } from '@renderer/services/TranslateService' import store, { useAppDispatch, useAppSelector } from '@renderer/store' import { setGenerating, setSearching } from '@renderer/store/runtime' -import { Assistant, FileType, Message, Topic } from '@renderer/types' +import { Assistant, FileType, KnowledgeBase, Message, Topic } from '@renderer/types' import { delay, getFileExtension, uuid } from '@renderer/utils' import { documentExts, imageExts, textExts } from '@shared/config/constant' import { Button, Popconfirm, Tooltip } from 'antd' @@ -38,6 +38,7 @@ import styled from 'styled-components' import AttachmentButton from './AttachmentButton' import AttachmentPreview from './AttachmentPreview' +import KnowledgeBaseButton from './KnowledgeBaseButton' import SendMessageButton from './SendMessageButton' import TokenCount from './TokenCount' @@ -48,6 +49,7 @@ interface Props { let _text = '' let _files: FileType[] = [] +let _base: KnowledgeBase | undefined const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { const [text, setText] = useState(_text) @@ -78,6 +80,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { const [spaceClickCount, setSpaceClickCount] = useState(0) const spaceClickTimer = useRef() const [isTranslating, setIsTranslating] = useState(false) + const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState(_base) const isVision = useMemo(() => isVisionModel(model), [model]) const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) @@ -90,6 +93,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { _text = text _files = files + _base = selectedKnowledgeBase const sendMessage = useCallback(async () => { if (generating) { @@ -111,6 +115,10 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { status: 'success' } + if (selectedKnowledgeBase) { + message.knowledgeBaseIds = [selectedKnowledgeBase.id] + } + if (files.length > 0) { message.files = await FileManager.uploadFiles(files) } @@ -123,7 +131,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { setTimeout(() => resizeTextArea(), 0) setExpend(false) - }, [assistant.id, assistant.topics, generating, files, text]) + }, [assistant.id, assistant.topics, generating, files, text, selectedKnowledgeBase]) const translate = async () => { if (isTranslating) { @@ -374,6 +382,10 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { const textareaRows = window.innerHeight >= 1000 || isBubbleStyle ? 2 : 1 + const handleKnowledgeBaseSelect = (base?: KnowledgeBase) => { + setSelectedKnowledgeBase(base) + } + return ( @@ -438,7 +450,19 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic }) => { - + 0} + /> + diff --git a/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx b/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx new file mode 100644 index 00000000..2ef33916 --- /dev/null +++ b/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx @@ -0,0 +1,83 @@ +import { FileSearchOutlined } from '@ant-design/icons' +import { useAppSelector } from '@renderer/store' +import { KnowledgeBase } from '@renderer/types' +import { Button, Popover, Tooltip } from 'antd' +import { FC } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface Props { + selectedBase?: KnowledgeBase + onSelect: (base?: KnowledgeBase) => void + disabled?: boolean + ToolbarButton?: any +} + +const KnowledgeBaseSelector: FC = ({ selectedBase, onSelect }) => { + const { t } = useTranslation() + const knowledgeState = useAppSelector((state) => state.knowledge) + + return ( + + {knowledgeState.bases.length === 0 ? ( + {t('knowledge.no_bases')} + ) : ( + <> + {selectedBase && ( + + )} + {knowledgeState.bases.map((base) => ( + + ))} + + )} + + ) +} + +const KnowledgeBaseButton: FC = ({ selectedBase, onSelect, disabled, ToolbarButton }) => { + const { t } = useTranslation() + + if (selectedBase) { + return ( + + onSelect(undefined)}> + + + + ) + } + + return ( + + } + trigger="click"> + selectedBase && onSelect(undefined)} disabled={disabled}> + + + + + ) +} + +const SelectorContainer = styled.div` + max-height: 300px; + overflow-y: auto; +` + +const EmptyMessage = styled.div` + padding: 8px; +` + +export default KnowledgeBaseButton diff --git a/src/renderer/src/pages/home/Messages/MessageTokens.tsx b/src/renderer/src/pages/home/Messages/MessageTokens.tsx index 7a046b83..8a782447 100644 --- a/src/renderer/src/pages/home/Messages/MessageTokens.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTokens.tsx @@ -29,18 +29,24 @@ const MessgeTokens: React.FC<{ message: Message; isLastMessage: boolean }> = ({ if (message.role === 'assistant') { let metrixs = '' + let hasMetrics = false + if (message?.metrics?.completion_tokens && message?.metrics?.time_completion_millsec) { + hasMetrics = true metrixs = t('settings.messages.metrics', { time_first_token_millsec: message?.metrics?.time_first_token_millsec, token_speed: (message?.metrics?.completion_tokens / (message?.metrics?.time_completion_millsec / 1000)).toFixed( - 2 + 0 ) }) } + return ( - - {metrixs !== '' ? metrixs : ''} - Tokens: {message?.usage?.total_tokens} ↑ {message?.usage?.prompt_tokens} ↓ {message?.usage?.completion_tokens} + + {metrixs} + + Tokens: {message?.usage?.total_tokens} ↑{message?.usage?.prompt_tokens} ↓{message?.usage?.completion_tokens} + ) } @@ -54,6 +60,25 @@ const MessageMetadata = styled.div` user-select: text; margin: 2px 0; cursor: pointer; + text-align: right; + + .metrics { + display: none; + } + + .tokens { + display: block; + } + + &.has-metrics:hover { + .metrics { + display: block; + } + + .tokens { + display: none; + } + } ` export default MessgeTokens diff --git a/src/renderer/src/pages/home/Navbar.tsx b/src/renderer/src/pages/home/Navbar.tsx index 4bd08cfd..8fc8affb 100644 --- a/src/renderer/src/pages/home/Navbar.tsx +++ b/src/renderer/src/pages/home/Navbar.tsx @@ -1,4 +1,4 @@ -import { SearchOutlined } from '@ant-design/icons' +import { FormOutlined, SearchOutlined } from '@ant-design/icons' import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar' import AssistantSettingsPopup from '@renderer/components/AssistantSettings' import { HStack } from '@renderer/components/Layout' @@ -47,8 +47,8 @@ const HeaderNavbar: FC = ({ activeAssistant }) => { - SearchPopup.show()}> - + EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}> + )} @@ -70,6 +70,9 @@ const HeaderNavbar: FC = ({ activeAssistant }) => { + SearchPopup.show()}> + + diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 5be2b38f..1654a093 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -162,7 +162,7 @@ const SettingsTab: FC = (props) => { - {t('model.stream_output')} + {t('models.stream_output')} = ({ selectedBase }) => { + const { t } = useTranslation() + const { + base, + noteItems, + fileItems, + urlItems, + sitemapItems, + directoryItems, + addFiles, + updateNoteContent, + addUrl, + addSitemap, + removeItem, + getProcessingStatus, + addNote, + addDirectory + } = useKnowledge(selectedBase.id || '') + + if (!base) { + return null + } + + const handleAddFile = () => { + const input = document.createElement('input') + input.type = 'file' + input.multiple = true + input.accept = fileTypes.join(',') + input.onchange = (e) => { + const files = (e.target as HTMLInputElement).files + files && handleDrop(Array.from(files)) + } + input.click() + } + + const handleDrop = async (files: File[]) => { + if (files) { + const _files: FileType[] = files.map((file) => ({ + id: file.name, + name: file.name, + path: file.path, + size: file.size, + ext: `.${file.name.split('.').pop()}`, + count: 1, + origin_name: file.name, + type: file.type as FileTypes, + created_at: new Date() + })) + console.debug('[KnowledgeContent] Uploading files:', _files, files) + const uploadedFiles = await FileManager.uploadFiles(_files) + addFiles(uploadedFiles) + } + } + + const handleAddUrl = async () => { + const url = await PromptPopup.show({ + title: t('knowledge_base.add_url'), + message: '', + inputPlaceholder: t('knowledge_base.url_placeholder'), + inputProps: { + maxLength: 1000, + rows: 1 + } + }) + + if (url) { + try { + new URL(url) + if (urlItems.find((item) => item.content === url)) { + message.success(t('knowledge_base.url_added')) + return + } + addUrl(url) + } catch (e) { + console.error('Invalid URL:', url) + } + } + } + + const handleAddSitemap = async () => { + const url = await PromptPopup.show({ + title: t('knowledge_base.add_sitemap'), + message: '', + inputPlaceholder: t('knowledge_base.sitemap_placeholder'), + inputProps: { + maxLength: 1000, + rows: 1 + } + }) + + if (url) { + try { + new URL(url) + if (sitemapItems.find((item) => item.content === url)) { + message.success(t('knowledge_base.sitemap_added')) + return + } + addSitemap(url) + } catch (e) { + console.error('Invalid Sitemap URL:', url) + } + } + } + + const handleAddNote = async () => { + const note = await TextEditPopup.show({ text: '', textareaProps: { rows: 20 } }) + note && addNote(note) + } + + const handleEditNote = async (note: any) => { + const editedText = await TextEditPopup.show({ text: note.content as string, textareaProps: { rows: 20 } }) + editedText && updateNoteContent(note.id, editedText) + } + + const handleAddDirectory = async () => { + const path = await window.api.file.selectFolder() + console.log('[KnowledgeContent] Selected directory:', path) + path && addDirectory(path) + } + + return ( + + + + {t('files.title')} + + + handleDrop([file as File])} + multiple={true} + accept={fileTypes.join(',')} + style={{ marginTop: 10, background: 'transparent' }}> +

{t('knowledge_base.drag_file')}

+

+ {t('knowledge_base.file_hint', { file_types: fileTypes.join(', ').replaceAll('.', '') })} +

+
+
+ + + {fileItems.map((item) => { + const file = item.content as FileType + return ( + + + + + window.api.file.openPath(file.path)}>{file.origin_name} + + + + + + + {directoryItems.map((item) => ( + + + + + window.api.file.openPath(item.content as string)}> + {item.content as string} + + + + + + + + {urlItems.map((item) => ( + + + + + + {item.content as string} + + + + + + + + {sitemapItems.map((item) => ( + + + + + + {item.content as string} + + + + + + + + {noteItems.map((note) => ( + + + handleEditNote(note)} style={{ cursor: 'pointer' }}> + {(note.content as string).slice(0, 50)}... + + + + + + +
+ ) +} + +const MainContent = styled(Scrollbar)` + display: flex; + width: 100%; + flex-direction: column; + padding-bottom: 50px; + padding: 15px; +` + +const FileSection = styled.div` + display: flex; + flex-direction: column; +` + +const ContentSection = styled.div` + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 10px; + + .ant-input-textarea { + background: var(--color-background-soft); + border-radius: 8px; + } +` + +const TitleWrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; + background-color: var(--color-background-soft); + padding: 5px 20px; + min-height: 45px; + border-radius: 6px; + .ant-typography { + margin-bottom: 0; + } +` + +const FileListSection = styled.div` + margin-top: 20px; + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +` + +const ItemCard = styled(Card)` + background-color: transparent; + border: none; + .ant-card-body { + padding: 0 20px; + } +` + +const ItemContent = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +` + +const ItemInfo = styled.div` + display: flex; + align-items: center; + gap: 8px; + flex: 1; + + a { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 600px; + } +` + +const IndexSection = styled.div` + margin-top: 20px; + display: flex; + justify-content: center; +` + +export default KnowledgeContent diff --git a/src/renderer/src/pages/knowledge/KnowledgePage.tsx b/src/renderer/src/pages/knowledge/KnowledgePage.tsx new file mode 100644 index 00000000..fbb54c59 --- /dev/null +++ b/src/renderer/src/pages/knowledge/KnowledgePage.tsx @@ -0,0 +1,219 @@ +import { DeleteOutlined, EditOutlined, FileTextOutlined, PlusOutlined } from '@ant-design/icons' +import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' +import DragableList from '@renderer/components/DragableList' +import ListItem from '@renderer/components/ListItem' +import PromptPopup from '@renderer/components/Popups/PromptPopup' +import Scrollbar from '@renderer/components/Scrollbar' +import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' +import { KnowledgeBase } from '@renderer/types' +import { Dropdown, Empty, MenuProps } from 'antd' +import { FC, useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import AddKnowledgePopup from './components/AddKnowledgePopup' +import KnowledgeContent from './KnowledgeContent' + +const KnowledgePage: FC = () => { + const { t } = useTranslation() + const { bases, renameKnowledgeBase, deleteKnowledgeBase, updateKnowledgeBases } = useKnowledgeBases() + const [selectedBase, setSelectedBase] = useState() + const [isDragging, setIsDragging] = useState(false) + + const handleAddKnowledge = async () => { + await AddKnowledgePopup.show({ title: t('knowledge_base.add.title') }) + } + + useEffect(() => { + if (bases.length > 0) { + if (!selectedBase) { + return setSelectedBase(bases[0]) + } + if (selectedBase && !bases.find((base) => base.id === selectedBase.id)) { + return setSelectedBase(bases[0]) + } + } + }, [bases, selectedBase]) + + const getMenuItems = useCallback( + (base: KnowledgeBase) => { + const menus: MenuProps['items'] = [ + { + label: t('knowledge_base.rename'), + key: 'rename', + icon: , + async onClick() { + const name = await PromptPopup.show({ + title: t('knowledge_base.rename'), + message: '', + defaultValue: base.name || '' + }) + if (name && base.name !== name) { + renameKnowledgeBase(base.id, name) + } + } + }, + { type: 'divider' }, + { + label: t('common.delete'), + danger: true, + key: 'delete', + icon: , + onClick: () => { + window.modal.confirm({ + title: t('knowledge_base.delete_confirm'), + centered: true, + onOk: () => { + deleteKnowledgeBase(base.id) + } + }) + } + } + ] + + return menus + }, + [deleteKnowledgeBase, renameKnowledgeBase, t] + ) + + return ( + + + {t('knowledge_base.title')} + + + + + setIsDragging(true)} + onDragEnd={() => setIsDragging(false)}> + {(base) => ( + +
+ } + title={base.name} + onClick={() => setSelectedBase(base)} + /> +
+
+ )} +
+ {!isDragging && ( + + + + {t('button.add')} + + + )} +
+
+
+ {bases.length === 0 ? ( + + + + ) : selectedBase ? ( + + ) : null} +
+
+ ) +} + +const Container = styled.div` + display: flex; + flex: 1; + flex-direction: column; + height: calc(100vh - var(--navbar-height)); +` + +const ContentContainer = styled.div` + display: flex; + flex: 1; + flex-direction: row; + min-height: 100%; +` + +const MainContent = styled(Scrollbar)` + padding: 15px 20px; + display: flex; + width: 100%; + flex-direction: column; + padding-bottom: 50px; +` + +const SideNav = styled.div` + width: var(--assistants-width); + border-right: 0.5px solid var(--color-border); + padding: 12px 10px; + display: flex; + flex-direction: column; + + .ant-menu { + border-inline-end: none !important; + background: transparent; + flex: 1; + } + + .ant-menu-item { + height: 40px; + line-height: 40px; + margin: 4px 0; + width: 100%; + + &:hover { + background-color: var(--color-background-soft); + } + + &.ant-menu-item-selected { + background-color: var(--color-background-soft); + color: var(--color-primary); + } + } +` + +const ScrollContainer = styled(Scrollbar)` + display: flex; + flex-direction: column; + flex: 1; + + > div { + margin-bottom: 8px; + + &:last-child { + margin-bottom: 0; + } + } +` + +const AddKnowledgeItem = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 7px 12px; + position: relative; + font-family: Ubuntu; + border-radius: 16px; + border: 0.5px solid transparent; + cursor: pointer; + &:hover { + background-color: var(--color-background-soft); + } +` + +const AddKnowledgeName = styled.div` + color: var(--color-text); + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 13px; +` + +export default KnowledgePage diff --git a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx new file mode 100644 index 00000000..7c2fdbcb --- /dev/null +++ b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx @@ -0,0 +1,126 @@ +import { TopView } from '@renderer/components/TopView' +import { isEmbeddingModel } from '@renderer/config/models' +import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' +import { useProviders } from '@renderer/hooks/useProvider' +import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService' +import { getModelUniqId } from '@renderer/services/ModelService' +import { Model } from '@renderer/types' +import { Form, Input, Modal, Select } from 'antd' +import { find, sortBy } from 'lodash' +import { nanoid } from 'nanoid' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface ShowParams { + title: string +} + +interface FormData { + name: string + model: string +} + +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ title, resolve }) => { + const [open, setOpen] = useState(true) + const [form] = Form.useForm() + const { t } = useTranslation() + const { providers } = useProviders() + const { addKnowledgeBase } = useKnowledgeBases() + const allModels = providers + .map((p) => p.models) + .flat() + .filter((model) => isEmbeddingModel(model)) + + const selectOptions = providers + .filter((p) => p.models.length > 0) + .map((p) => ({ + label: p.isSystem ? t(`provider.${p.id}`) : p.name, + title: p.name, + options: sortBy(p.models, 'name') + .filter((model) => isEmbeddingModel(model)) + .map((m) => ({ + label: m.name, + value: getModelUniqId(m) + })) + })) + .filter((group) => group.options.length > 0) + + const onOk = async () => { + try { + const values = await form.validateFields() + const selectedModel = find(allModels, JSON.parse(values.model)) as Model + + if (selectedModel) { + const newBase = { + id: nanoid(), + name: values.name, + model: selectedModel, + items: [], + created_at: Date.now(), + updated_at: Date.now() + } + + await window.api.knowledgeBase.create(getKnowledgeBaseParams(newBase)) + + addKnowledgeBase(newBase as any) + setOpen(false) + resolve(newBase) + } + } catch (error) { + console.error('Validation failed:', error) + } + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve(null) + } + + return ( + +
+ + + + + +