Merge branch 'knowledge'

This commit is contained in:
kangfenmao 2024-12-24 09:38:38 +08:00
commit fbd189c5e1
68 changed files with 4888 additions and 858 deletions

View File

@ -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

1
.gitignore vendored
View File

@ -36,6 +36,7 @@ node_modules
dist
out
build/icons
stats.html
# ENV
.env

View File

@ -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}`);
}

View File

@ -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;

View File

@ -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模型默认开启流式输出
长文本粘贴为文件支持修改阈值
增加知识库功能

View File

@ -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
}
}
})

View File

@ -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"
}

View File

@ -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: '请稍<E8AFB7><E7A88D><EFBFBD>再试'
}), {
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)
}
}

58
scripts/removeLocales.js Normal file
View File

@ -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!')
}

View File

@ -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)
}

View File

@ -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<void> => {
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
}
public save = async (
_: Electron.IpcMainInvokeEvent,
fileName: string,

View File

@ -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<RAGApplication> => {
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<void> => {
this.getRagApplication({ id, model, apiKey, baseURL })
}
public reset = async (_: Electron.IpcMainInvokeEvent, { base }: { base: KnowledgeBaseParams }): Promise<void> => {
const ragApplication = await this.getRagApplication(base)
await ragApplication.reset()
}
public delete = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
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<AddLoaderReturn> => {
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<void> => {
const ragApplication = await this.getRagApplication(base)
await ragApplication.deleteLoader(uniqueId)
}
public search = async (
_: Electron.IpcMainInvokeEvent,
{ search, base }: { search: string; base: KnowledgeBaseParams }
): Promise<ExtractChunkData[]> => {
const ragApplication = await this.getRagApplication(base)
return await ragApplication.search(search)
}
}
export default new KnowledgeService()

View File

@ -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' }
})

View File

@ -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<string>
write: (filePath: string, data: Uint8Array | string) => Promise<void>
open: (options?: OpenDialogOptions) => Promise<{ fileName: string; filePath: string; content: Buffer } | null>
openPath: (path: string) => Promise<void>
save: (
path: string,
content: string | NodeJS.ArrayBufferView,
@ -58,6 +60,22 @@ declare global {
shortcuts: {
update: (shortcuts: Shortcut[]) => Promise<void>
}
knowledgeBase: {
create: ({ id, model, apiKey, baseURL }: KnowledgeBaseParams) => Promise<void>
reset: ({ base }: { base: KnowledgeBaseParams }) => Promise<void>
delete: (id: string) => Promise<void>
add: ({
base,
item,
forceReload = false
}: {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload?: boolean
}) => Promise<AddLoaderReturn>
remove: ({ uniqueId, base }: { uniqueId: string; base: KnowledgeBaseParams }) => Promise<void>
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) => Promise<ExtractChunkData[]>
}
}
}
}

View File

@ -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 })
}
}

View File

@ -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 {
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings" element={<PaintingsPage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.45 66.73">
<defs>
<style>
.cls-1 {
fill: #ea5e5d;
}
.cls-2 {
fill: #23af69;
}
.cls-3 {
fill: #ea5756;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层_1">
<g>
<g>
<g>
<path class="cls-1" d="M16.72,51.21c-4.45,0-8.64-1.78-11.81-5.01-3.17-3.23-4.91-7.51-4.91-12.04s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.71,1.82,11.82,4.99c2.32,2.36,2.32,6.2,0,8.56-2.32,2.36-6.08,2.36-8.4,0-.9-.92-2.15-1.45-3.43-1.45-2.63,0-4.85,2.26-4.85,4.94s2.22,4.94,4.85,4.94c1.28,0,2.52-.53,3.43-1.45,2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-3.11,3.17-7.42,4.99-11.82,4.99Z"/>
<path class="cls-1" d="M32.05,66.73c-4.45,0-8.64-1.78-11.81-5.01s-4.91-7.51-4.91-12.04,1.79-8.88,4.9-12.06c2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-.9.92-1.42,2.19-1.42,3.49,0,2.68,2.22,4.94,4.85,4.94s4.85-2.26,4.85-4.94c0-.95-.23-2.31-1.32-3.43-3.13-3.19-4.92-7.6-4.92-12.09s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.64,1.78,11.81,5.01,4.91,7.51,4.91,12.04-1.79,8.88-4.9,12.06c-2.32,2.36-6.08,2.36-8.4,0-2.32-2.36-2.32-6.2,0-8.56.9-.92,1.42-2.19,1.42-3.49,0-2.68-2.22-4.94-4.85-4.94s-4.85,2.26-4.85,4.94c0,1.31.53,2.6,1.45,3.53,3.1,3.16,4.8,7.42,4.8,11.99s-1.74,8.81-4.91,12.04c-3.17,3.23-7.36,5.01-11.81,5.01Z"/>
</g>
<path class="cls-2" d="M32.05,19.09l-9.72-9.12c-1.5-1.4-1.57-3.75-.17-5.25,1.4-1.49,3.75-1.57,5.25-.17l3.89,3.65,5.53-6.83c1.29-1.59,3.63-1.84,5.22-.55,1.59,1.29,1.84,3.63.55,5.22l-10.56,13.05Z"/>
</g>
<g>
<path class="cls-3" d="M93.93,24.6l.55-.39c.69-.4,1.17-.61,1.46-.61.63,0,1.3.57,2.03,1.7.44.71.67,1.27.67,1.7s-.14.78-.41,1.06c-.27.28-.59.54-.96.76-.36.22-.71.43-1.05.64-.33.2-1.02.47-2.05.79-1.03.32-2.03.49-2.99.49s-1.93-.13-2.91-.38c-.98-.25-1.99-.68-3.03-1.27-1.04-.6-1.98-1.32-2.81-2.18-.83-.86-1.51-1.96-2.05-3.31-.54-1.35-.8-2.81-.8-4.38s.26-3.01.79-4.29c.53-1.28,1.2-2.35,2.02-3.19.82-.84,1.75-1.54,2.81-2.11,1.98-1.09,3.97-1.64,5.98-1.64.95,0,1.92.15,2.9.44.98.29,1.72.59,2.23.9l.73.42c.36.22.65.4.85.55.53.42.79.91.79,1.44s-.21,1.1-.64,1.68c-.79,1.09-1.5,1.64-2.12,1.64-.36,0-.88-.22-1.55-.67-.85-.69-1.98-1.03-3.4-1.03-1.31,0-2.61.46-3.88,1.36-.61.44-1.11,1.07-1.52,1.88-.4.81-.61,1.72-.61,2.75s.2,1.94.61,2.75c.4.81.92,1.45,1.55,1.91,1.23.89,2.52,1.34,3.85,1.34.63,0,1.22-.08,1.77-.24.56-.16.96-.32,1.2-.49Z"/>
<path class="cls-3" d="M114.38,9.07c.16-.3.43-.52.82-.64.38-.12.87-.18,1.46-.18s1.05.05,1.4.15c.34.1.61.22.79.36.18.14.32.34.42.61.1.34.15.87.15,1.58v16.84c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58v-6.16h-8.04v6.19c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58V10.92c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82,1.42,0,2.25.37,2.52,1.12.1.34.15.87.15,1.58v6.19h8.04v-6.22c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8Z"/>
<path class="cls-3" d="M127.21,25.1h9.34c.47,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.37,2.25-1.12,2.49-.34.12-.87.18-1.58.18h-12.01c-1.42,0-2.25-.38-2.49-1.15-.12-.32-.18-.84-.18-1.55V10.9c0-1.03.19-1.73.58-2.11.38-.37,1.11-.56,2.18-.56h11.95c.47,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.37,2.25-1.12,2.49-.34.12-.87.18-1.58.18h-9.31v3.06h6.01c.46,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.38,2.25-1.15,2.49-.34.12-.87.18-1.58.18h-5.95v3.06Z"/>
<path class="cls-3" d="M196.96,8.79c.99.69,1.49,1.35,1.49,2,0,.38-.23.92-.7,1.61l-6.55,9.8v5.79c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.16.3-.43.52-.82.64-.38.12-.9.18-1.55.18s-1.16-.06-1.55-.18c-.38-.12-.66-.34-.82-.65-.16-.31-.26-.59-.29-.82-.03-.23-.05-.59-.05-1.08v-5.73l-6.55-9.8c-.47-.69-.7-1.22-.7-1.61,0-.65.44-1.27,1.33-1.87.89-.6,1.53-.9,1.91-.9s.69.08.91.24c.34.22.71.64,1.09,1.24l4.7,7.52,4.7-7.52c.38-.61.72-1.01,1-1.2s.61-.29.99-.29.97.25,1.77.76Z"/>
<g>
<path class="cls-3" d="M81.93,56.63c-.53-.65-.79-1.23-.79-1.74s.43-1.2,1.3-2.05c.51-.49,1.04-.73,1.61-.73s1.36.51,2.37,1.52c.28.34.69.67,1.21.99.53.31,1.01.47,1.46.47,1.88,0,2.82-.77,2.82-2.31,0-.46-.26-.85-.77-1.17-.52-.31-1.16-.54-1.93-.68-.77-.14-1.6-.37-2.49-.68-.89-.31-1.72-.68-2.49-1.11-.77-.42-1.41-1.1-1.93-2.02-.52-.92-.77-2.03-.77-3.32,0-1.78.66-3.33,1.99-4.66s3.13-1.99,5.42-1.99c1.21,0,2.32.16,3.32.47,1,.31,1.69.63,2.08.96l.76.58c.63.59.94,1.08.94,1.49s-.24.96-.73,1.67c-.69,1.01-1.4,1.52-2.12,1.52-.42,0-.95-.2-1.58-.61-.06-.04-.18-.14-.35-.3-.17-.16-.33-.29-.47-.39-.42-.26-.97-.39-1.62-.39s-1.2.16-1.64.47c-.43.31-.65.75-.65,1.3s.26,1.01.77,1.35c.52.34,1.16.58,1.93.7.77.12,1.61.31,2.52.56.91.25,1.75.56,2.52.93.77.36,1.41,1,1.93,1.9.52.9.77,2.01.77,3.32s-.26,2.47-.79,3.47c-.53,1-1.21,1.77-2.06,2.32-1.64,1.07-3.39,1.61-5.25,1.61-.95,0-1.85-.12-2.7-.35-.85-.23-1.54-.52-2.06-.86-1.07-.65-1.82-1.27-2.24-1.88l-.27-.33Z"/>
<path class="cls-3" d="M100.74,37.49h16.87c.65,0,1.12.08,1.43.23.3.15.51.39.61.71.1.32.15.75.15,1.27s-.05.95-.15,1.26c-.1.31-.27.53-.52.65-.36.18-.88.27-1.55.27h-5.79v15.26c0,.47-.02.81-.05,1.03s-.12.48-.27.77c-.15.29-.42.5-.8.62-.38.12-.89.18-1.52.18s-1.13-.06-1.5-.18c-.37-.12-.64-.33-.79-.62-.15-.29-.24-.56-.27-.79-.03-.23-.05-.58-.05-1.05v-15.23h-5.82c-.65,0-1.12-.08-1.43-.23-.3-.15-.51-.39-.61-.71-.1-.32-.15-.75-.15-1.27s.05-.95.15-1.26c.1-.31.27-.53.52-.65.36-.18.88-.27,1.55-.27Z"/>
<path class="cls-3" d="M135.99,38.34c.2-.32.5-.55.88-.67.38-.12.86-.18,1.44-.18s1.04.05,1.38.15c.34.1.61.22.79.36.18.14.31.35.39.64.12.34.18.87.18,1.58v9.16c0,2.67-.83,5.1-2.49,7.28-.81,1.03-1.85,1.87-3.12,2.5s-2.68.96-4.23.96-2.95-.32-4.22-.97c-1.26-.65-2.29-1.5-3.08-2.55-1.64-2.14-2.46-4.57-2.46-7.28v-9.13c0-.49.02-.84.05-1.08.03-.23.13-.5.29-.8.16-.3.43-.52.82-.64.38-.12.9-.18,1.55-.18s1.16.06,1.55.18c.38.12.65.33.79.64.24.47.36,1.1.36,1.91v9.1c0,1.23.3,2.41.91,3.52.3.57.76,1.02,1.37,1.36.61.34,1.32.52,2.15.52,1.48,0,2.58-.55,3.31-1.64.73-1.09,1.09-2.36,1.09-3.79v-9.28c0-.79.1-1.34.3-1.67Z"/>
<path class="cls-3" d="M146.18,37.49l5.61.03c2.93,0,5.51,1.06,7.74,3.17,2.22,2.11,3.34,4.71,3.34,7.8s-1.09,5.73-3.26,7.93c-2.17,2.2-4.81,3.31-7.9,3.31h-5.55c-1.23,0-2-.25-2.31-.76-.24-.42-.36-1.07-.36-1.94v-16.87c0-.49.02-.84.05-1.06s.13-.49.29-.79c.28-.55,1.07-.82,2.37-.82ZM151.79,54.35c1.46,0,2.77-.54,3.94-1.62,1.17-1.08,1.76-2.44,1.76-4.08s-.57-3.01-1.71-4.11c-1.14-1.1-2.48-1.65-4.02-1.65h-2.91v11.47h2.94Z"/>
<path class="cls-3" d="M164.84,40.19c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82,1.42,0,2.25.37,2.52,1.12.1.34.15.87.15,1.58v16.87c0,.49-.02.84-.05,1.06s-.13.49-.29.79c-.28.55-1.07.82-2.37.82-1.42,0-2.25-.38-2.49-1.15-.12-.32-.18-.84-.18-1.55v-16.87Z"/>
<path class="cls-3" d="M183.07,37.24c2.99,0,5.59,1.08,7.8,3.25,2.2,2.16,3.31,4.85,3.31,8.05s-1.05,5.94-3.16,8.19c-2.1,2.26-4.69,3.38-7.77,3.38s-5.69-1.11-7.84-3.34c-2.15-2.22-3.23-4.87-3.23-7.95,0-1.68.3-3.25.91-4.72.61-1.47,1.42-2.7,2.43-3.69,1.01-.99,2.17-1.77,3.49-2.34,1.31-.57,2.67-.85,4.07-.85ZM177.55,48.68c0,1.8.58,3.26,1.74,4.38,1.16,1.12,2.46,1.68,3.9,1.68s2.73-.55,3.88-1.64c1.15-1.09,1.73-2.56,1.73-4.4s-.58-3.32-1.74-4.43c-1.16-1.11-2.46-1.67-3.9-1.67s-2.73.56-3.88,1.68c-1.15,1.12-1.73,2.58-1.73,4.38Z"/>
</g>
<g>
<path class="cls-3" d="M176.92,11.06c-.03-.23-.13-.5-.29-.8-.28-.55-1.07-.82-2.37-.82h-6.55c-1.78,0-3.51.65-5.19,1.94-.81.63-1.48,1.48-2,2.55-.53,1.07-.79,2.27-.79,3.58,0,2.29.76,4.17,2.28,5.64-.44,1.07-1.13,2.66-2.06,4.76-.3.73-.45,1.25-.45,1.58,0,.77.63,1.42,1.88,1.94.65.28,1.17.43,1.56.43s.72-.1.97-.29c.25-.19.44-.39.56-.59.2-.38.99-2.21,2.37-5.49l.94.06h3.82v3.43c0,.47.02.81.05,1.05.03.23.13.5.29.8.28.55,1.07.82,2.37.82,1.42,0,2.25-.37,2.49-1.12.12-.34.18-.87.18-1.58V12.11c0-.46-.02-.81-.05-1.05ZM172.81,19.44c-.09.14-.48.77-1.24.91-.2.04-.37.03-.48.02-.02.14-.04.26-.06.38-.16.83-.38,1.05-.57,1.07-.29.05-.51-.35-.93-.9-.23.01-.46.02-.69.02-.51,0-1.01-.03-1.49-.09-.25-.03-.5-.07-.74-.11-1.18-.32-2.03-1.27-2.03-2.4v-1.37c0-1.13.86-2.08,2.03-2.4.24-.04.49-.08.74-.11.48-.06.98-.09,1.49-.09s1.01.03,1.49.09c.25.03.5.07.74.11.6.16,1.12.49,1.49.93.34.41.55.92.55,1.47v1.37c0,.23-.01.66-.29,1.1Z"/>
<circle class="cls-2" cx="167.24" cy="17.67" r=".49"/>
<circle class="cls-2" cx="168.88" cy="17.71" r=".49"/>
<circle class="cls-2" cx="170.59" cy="17.71" r=".49"/>
</g>
<g>
<path class="cls-3" d="M141.01,8.24c.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82h6.55c1.78,0,3.51.65,5.19,1.94.81.63,1.48,1.48,2,2.55.53,1.07.79,2.27.79,3.58,0,2.29-.76,4.17-2.28,5.64.44,1.07,1.13,2.66,2.06,4.76.3.73.45,1.25.45,1.58,0,.77-.63,1.42-1.88,1.94-.65.28-1.17.43-1.56.43s-.72-.1-.97-.29c-.25-.19-.44-.39-.56-.59-.2-.38-.99-2.21-2.37-5.49l-.94.06h-3.82v3.43c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58V9.28c0-.46.02-.81.05-1.05ZM145.12,16.62c.09.14.48.77,1.24.91.2.04.37.03.48.02.02.14.04.26.06.38.16.83.38,1.05.57,1.07.29.05.51-.35.93-.9.23.01.46.02.69.02.51,0,1.01-.03,1.49-.09.25-.03.5-.07.74-.11,1.18-.32,2.03-1.27,2.03-2.4v-1.37c0-1.13-.86-2.08-2.03-2.4-.24-.04-.49-.08-.74-.11-.48-.06-.98-.09-1.49-.09s-1.01.03-1.49.09c-.25.03-.5.07-.74.11-.6.16-1.12.49-1.49.93-.34.41-.55.92-.55,1.47v1.37c0,.23.01.66.29,1.1Z"/>
<circle class="cls-2" cx="150.69" cy="14.84" r=".49"/>
<circle class="cls-2" cx="149.05" cy="14.89" r=".49"/>
<circle class="cls-2" cx="147.35" cy="14.89" r=".49"/>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.5 KiB

View File

@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84.39 115.44">
<defs>
<style>
.cls-1 {
fill: #ea5e5d;
}
.cls-2 {
fill: #23af69;
}
.cls-3 {
fill: #ea5756;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层_1">
<g>
<g>
<g>
<path class="cls-1" d="M25.31,51.21c-4.45,0-8.64-1.78-11.81-5.01-3.17-3.23-4.91-7.51-4.91-12.04s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.71,1.82,11.82,4.99c2.32,2.36,2.32,6.2,0,8.56-2.32,2.36-6.08,2.36-8.4,0-.9-.92-2.15-1.45-3.43-1.45-2.63,0-4.85,2.26-4.85,4.94s2.22,4.94,4.85,4.94c1.28,0,2.52-.53,3.43-1.45,2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-3.11,3.17-7.42,4.99-11.82,4.99Z"/>
<path class="cls-1" d="M40.64,66.73c-4.45,0-8.64-1.78-11.81-5.01s-4.91-7.51-4.91-12.04,1.79-8.88,4.9-12.06c2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-.9.92-1.42,2.19-1.42,3.49,0,2.68,2.22,4.94,4.85,4.94s4.85-2.26,4.85-4.94c0-.95-.23-2.31-1.32-3.43-3.13-3.19-4.92-7.6-4.92-12.09s1.74-8.81,4.91-12.04c3.17-3.23,7.36-5.01,11.81-5.01s8.64,1.78,11.81,5.01,4.91,7.51,4.91,12.04-1.79,8.88-4.9,12.06c-2.32,2.36-6.08,2.36-8.4,0-2.32-2.36-2.32-6.2,0-8.56.9-.92,1.42-2.19,1.42-3.49,0-2.68-2.22-4.94-4.85-4.94s-4.85,2.26-4.85,4.94c0,1.31.53,2.6,1.45,3.53,3.1,3.16,4.8,7.42,4.8,11.99s-1.74,8.81-4.91,12.04c-3.17,3.23-7.36,5.01-11.81,5.01Z"/>
</g>
<path class="cls-2" d="M40.64,19.09l-9.72-9.12c-1.5-1.4-1.57-3.75-.17-5.25,1.4-1.49,3.75-1.57,5.25-.17l3.89,3.65,5.53-6.83c1.29-1.59,3.63-1.84,5.22-.55,1.59,1.29,1.84,3.63.55,5.22l-10.56,13.05Z"/>
</g>
<g>
<path class="cls-3" d="M10.19,90.22l.39-.28c.49-.29.83-.43,1.03-.43.45,0,.93.4,1.44,1.21.32.5.47.9.47,1.21s-.1.55-.29.75c-.19.2-.42.38-.68.54-.26.16-.51.31-.74.45-.24.14-.72.33-1.45.56-.73.23-1.44.34-2.12.34s-1.37-.09-2.07-.27c-.7-.18-1.41-.48-2.15-.9-.74-.42-1.4-.94-1.99-1.55-.59-.61-1.07-1.39-1.45-2.35-.38-.96-.57-1.99-.57-3.11s.19-2.14.56-3.05c.37-.91.85-1.67,1.43-2.26.58-.6,1.25-1.09,1.99-1.5,1.41-.78,2.82-1.16,4.24-1.16.67,0,1.36.1,2.06.31.7.21,1.22.42,1.58.64l.52.3c.26.16.46.29.6.39.37.3.56.64.56,1.02s-.15.78-.45,1.2c-.56.78-1.06,1.16-1.51,1.16-.26,0-.62-.16-1.1-.47-.6-.49-1.41-.73-2.41-.73-.93,0-1.85.32-2.76.97-.43.32-.79.76-1.08,1.34-.29.57-.43,1.22-.43,1.95s.14,1.37.43,1.95c.29.57.65,1.03,1.1,1.36.88.63,1.79.95,2.74.95.45,0,.87-.06,1.26-.17.39-.11.68-.23.85-.34Z"/>
<path class="cls-3" d="M24.7,79.2c.11-.22.31-.37.58-.45.27-.09.62-.13,1.03-.13s.75.04.99.11c.24.07.43.16.56.26.13.1.23.24.3.43.07.24.11.62.11,1.12v11.95c0,.33-.01.58-.03.74-.02.17-.09.36-.2.57-.2.39-.76.58-1.68.58-1.01,0-1.59-.27-1.77-.8-.09-.24-.13-.62-.13-1.12v-4.37h-5.71v4.39c0,.33-.01.58-.03.74-.02.17-.09.36-.2.57-.2.39-.76.58-1.68.58-1.01,0-1.59-.27-1.77-.8-.09-.24-.13-.62-.13-1.12v-11.95c0-.33.01-.58.03-.74.02-.17.09-.36.2-.57.2-.39.76-.58,1.68-.58,1.01,0,1.6.27,1.79.8.07.24.11.62.11,1.12v4.39h5.71v-4.42c0-.33.01-.58.03-.74.02-.17.09-.36.2-.57Z"/>
<path class="cls-3" d="M33.82,90.58h6.63c.33,0,.58.01.74.03.17.02.36.09.57.2.39.2.58.76.58,1.68,0,1.01-.27,1.59-.8,1.77-.24.09-.62.13-1.12.13h-8.53c-1.01,0-1.59-.27-1.77-.82-.09-.23-.13-.6-.13-1.1v-11.98c0-.73.14-1.23.41-1.5.27-.27.79-.4,1.55-.4h8.49c.33,0,.58.01.74.03.17.02.36.09.57.2.39.2.58.76.58,1.68,0,1.01-.27,1.59-.8,1.77-.24.09-.62.13-1.12.13h-6.61v2.18h4.26c.33,0,.58.01.74.03.17.02.36.09.57.2.39.2.58.76.58,1.68,0,1.01-.27,1.59-.82,1.77-.24.09-.62.13-1.12.13h-4.22v2.18Z"/>
<path class="cls-3" d="M83.34,79c.7.49,1.06.96,1.06,1.42,0,.27-.17.65-.5,1.14l-4.65,6.96v4.11c0,.33-.01.58-.03.74-.02.17-.09.36-.2.57-.11.22-.31.37-.58.45-.27.09-.64.13-1.1.13s-.83-.04-1.1-.13c-.27-.09-.47-.24-.58-.46-.11-.22-.18-.42-.2-.58-.02-.17-.03-.42-.03-.76v-4.07l-4.65-6.96c-.33-.49-.5-.87-.5-1.14,0-.46.32-.9.95-1.32.63-.42,1.08-.64,1.36-.64s.49.06.65.17c.24.16.5.45.78.88l3.34,5.34,3.34-5.34c.27-.43.51-.71.71-.85s.43-.2.7-.2.69.18,1.26.54Z"/>
<g>
<path class="cls-3" d="M1.66,112.96c-.37-.46-.56-.87-.56-1.24s.31-.85.93-1.45c.36-.34.74-.52,1.14-.52s.96.36,1.68,1.08c.2.24.49.48.86.7.37.22.72.33,1.03.33,1.34,0,2-.55,2-1.64,0-.33-.18-.61-.55-.83-.37-.22-.82-.38-1.37-.48-.55-.1-1.13-.26-1.77-.48-.63-.22-1.22-.48-1.77-.79-.55-.3-1-.78-1.37-1.43-.37-.65-.55-1.44-.55-2.36,0-1.26.47-2.37,1.41-3.31s2.22-1.41,3.84-1.41c.86,0,1.65.11,2.36.33.71.22,1.2.45,1.48.68l.54.41c.45.42.67.77.67,1.06s-.17.68-.52,1.18c-.49.72-.99,1.08-1.51,1.08-.3,0-.67-.14-1.12-.43-.04-.03-.13-.1-.25-.22-.12-.11-.23-.21-.33-.28-.3-.19-.69-.28-1.15-.28s-.85.11-1.16.33c-.31.22-.46.53-.46.93s.18.71.55.96c.37.24.82.41,1.37.5.55.09,1.14.22,1.79.4.65.18,1.24.4,1.79.66.55.26,1,.71,1.37,1.35.37.64.55,1.42.55,2.36s-.19,1.76-.56,2.47c-.37.71-.86,1.26-1.46,1.65-1.16.76-2.4,1.14-3.73,1.14-.68,0-1.31-.08-1.92-.25-.6-.17-1.09-.37-1.46-.61-.76-.46-1.29-.9-1.59-1.34l-.19-.24Z"/>
<path class="cls-3" d="M15.02,99.37h11.98c.46,0,.8.05,1.01.16.22.11.36.28.43.51.07.23.11.53.11.9s-.04.67-.11.89c-.07.22-.19.38-.37.46-.26.13-.62.19-1.1.19h-4.11v10.83c0,.33-.01.57-.03.73s-.09.34-.19.55c-.11.21-.3.36-.57.44-.27.09-.63.13-1.08.13s-.8-.04-1.07-.13c-.27-.09-.45-.23-.56-.44-.11-.21-.17-.4-.19-.56-.02-.17-.03-.41-.03-.74v-10.81h-4.14c-.46,0-.8-.05-1.01-.16-.22-.11-.36-.28-.43-.51-.07-.23-.11-.53-.11-.9s.04-.67.11-.89c.07-.22.19-.38.37-.46.26-.13.62-.19,1.1-.19Z"/>
<path class="cls-3" d="M40.05,99.98c.14-.23.35-.39.62-.47.27-.09.61-.13,1.02-.13s.74.04.98.11c.24.07.43.16.56.26.13.1.22.25.28.45.09.24.13.62.13,1.12v6.5c0,1.9-.59,3.62-1.77,5.17-.57.73-1.31,1.32-2.22,1.78s-1.91.68-3,.68-2.1-.23-2.99-.69c-.9-.46-1.63-1.06-2.19-1.81-1.16-1.52-1.74-3.25-1.74-5.17v-6.48c0-.34.01-.6.03-.76.02-.17.09-.36.2-.57.11-.22.31-.37.58-.45.27-.09.64-.13,1.1-.13s.83.04,1.1.13c.27.09.46.24.56.45.17.33.26.78.26,1.36v6.46c0,.88.22,1.71.65,2.5.22.4.54.72.97.97.43.24.94.37,1.53.37,1.05,0,1.83-.39,2.35-1.16.52-.78.78-1.67.78-2.69v-6.59c0-.56.07-.95.22-1.18Z"/>
<path class="cls-3" d="M47.28,99.37l3.98.02c2.08,0,3.91.75,5.49,2.25,1.58,1.5,2.37,3.35,2.37,5.54s-.77,4.07-2.32,5.63c-1.54,1.57-3.41,2.35-5.61,2.35h-3.94c-.88,0-1.42-.18-1.64-.54-.17-.3-.26-.76-.26-1.38v-11.98c0-.34.01-.6.03-.75s.09-.34.2-.56c.2-.39.76-.58,1.68-.58ZM51.27,111.35c1.03,0,1.97-.38,2.8-1.15.83-.77,1.25-1.73,1.25-2.9s-.41-2.14-1.22-2.92c-.81-.78-1.76-1.17-2.85-1.17h-2.07v8.14h2.09Z"/>
<path class="cls-3" d="M60.53,101.29c0-.33.01-.58.03-.74.02-.17.09-.36.2-.57.2-.39.76-.58,1.68-.58,1.01,0,1.6.27,1.79.8.07.24.11.62.11,1.12v11.98c0,.34-.01.6-.03.75s-.09.34-.2.56c-.2.39-.76.58-1.68.58-1.01,0-1.59-.27-1.77-.82-.09-.23-.13-.6-.13-1.1v-11.98Z"/>
<path class="cls-3" d="M73.47,99.2c2.13,0,3.97.77,5.54,2.3,1.57,1.54,2.35,3.44,2.35,5.72s-.75,4.21-2.24,5.82c-1.49,1.6-3.33,2.4-5.51,2.4s-4.04-.79-5.57-2.37c-1.53-1.58-2.29-3.46-2.29-5.64,0-1.19.22-2.31.65-3.35.43-1.04,1.01-1.91,1.72-2.62.72-.7,1.54-1.26,2.48-1.66.93-.4,1.9-.6,2.89-.6ZM69.55,107.32c0,1.28.41,2.32,1.24,3.11.83.8,1.75,1.2,2.77,1.2s1.94-.39,2.76-1.16c.82-.78,1.23-1.82,1.23-3.12s-.41-2.35-1.24-3.14c-.83-.79-1.75-1.18-2.77-1.18s-1.94.4-2.76,1.2c-.82.8-1.23,1.83-1.23,3.11Z"/>
</g>
<g>
<path class="cls-3" d="M69.11,80.61c-.02-.17-.09-.36-.2-.57-.2-.39-.76-.58-1.68-.58h-4.65c-1.26,0-2.49.46-3.68,1.38-.57.45-1.05,1.05-1.42,1.81-.37.76-.56,1.61-.56,2.54,0,1.62.54,2.96,1.62,4.01-.32.76-.8,1.89-1.46,3.38-.22.52-.32.89-.32,1.12,0,.55.45,1.01,1.34,1.38.46.2.83.3,1.11.3s.51-.07.69-.2c.18-.14.31-.28.4-.42.14-.27.7-1.57,1.68-3.9l.67.04h2.71v2.43c0,.33.01.58.03.74.02.17.09.36.2.57.2.39.76.58,1.68.58,1.01,0,1.59-.27,1.77-.8.09-.24.13-.62.13-1.12v-11.95c0-.33-.01-.58-.03-.74ZM66.19,86.56c-.06.1-.34.54-.88.65-.14.03-.26.02-.34.02-.01.1-.03.19-.04.27-.11.59-.27.74-.4.76-.21.03-.36-.25-.66-.64-.16,0-.32.01-.49.01-.36,0-.72-.02-1.06-.06-.18-.02-.35-.05-.52-.08-.84-.22-1.44-.9-1.44-1.7v-.97c0-.8.61-1.48,1.44-1.7.17-.03.34-.06.52-.08.34-.04.69-.06,1.06-.06s.72.02,1.06.06c.18.02.35.05.52.08.43.12.8.35,1.06.66.24.29.39.65.39,1.05v.97c0,.16,0,.47-.21.78Z"/>
<circle class="cls-2" cx="62.23" cy="85.3" r=".35"/>
<circle class="cls-2" cx="63.4" cy="85.33" r=".35"/>
<circle class="cls-2" cx="64.61" cy="85.33" r=".35"/>
</g>
<g>
<path class="cls-3" d="M43.62,78.61c.02-.17.09-.36.2-.57.2-.39.76-.58,1.68-.58h4.65c1.26,0,2.49.46,3.68,1.38.57.45,1.05,1.05,1.42,1.81.37.76.56,1.61.56,2.54,0,1.62-.54,2.96-1.62,4.01.32.76.8,1.89,1.46,3.38.22.52.32.89.32,1.12,0,.55-.45,1.01-1.34,1.38-.46.2-.83.3-1.11.3s-.51-.07-.69-.2c-.18-.14-.31-.28-.4-.42-.14-.27-.7-1.57-1.68-3.9l-.67.04h-2.71v2.43c0,.33-.01.58-.03.74-.02.17-.09.36-.2.57-.2.39-.76.58-1.68.58-1.01,0-1.59-.27-1.77-.8-.09-.24-.13-.62-.13-1.12v-11.95c0-.33.01-.58.03-.74ZM46.53,84.56c.06.1.34.54.88.65.14.03.26.02.34.02.01.1.03.19.04.27.11.59.27.74.4.76.21.03.36-.25.66-.64.16,0,.32.01.49.01.36,0,.72-.02,1.06-.06.18-.02.35-.05.52-.08.84-.22,1.44-.9,1.44-1.7v-.97c0-.8-.61-1.48-1.44-1.7-.17-.03-.34-.06-.52-.08-.34-.04-.69-.06-1.06-.06s-.72.02-1.06.06c-.18.02-.35.05-.52.08-.43.12-.8.35-1.06.66-.24.29-.39.65-.39,1.05v.97c0,.16,0,.47.21.78Z"/>
<circle class="cls-2" cx="50.49" cy="83.3" r=".35"/>
<circle class="cls-2" cx="49.32" cy="83.33" r=".35"/>
<circle class="cls-2" cx="48.11" cy="83.33" r=".35"/>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -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);

View File

@ -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 {

View File

@ -241,7 +241,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
)}
<Divider style={{ margin: '10px 0' }} />
<SettingRow style={{ minHeight: 30 }}>
<Label>{t('model.stream_output')}</Label>
<Label>{t('models.stream_output')}</Label>
<Switch
checked={streamOutput}
onChange={(checked) => {

View File

@ -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 (
<ListItemContainer className={active ? 'active' : ''} onClick={onClick}>
<ListItemContent>
{icon && <span style={{ marginRight: '8px' }}>{icon}</span>}
{title}
</ListItemContent>
</ListItemContainer>
)
}
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

View File

@ -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<PopupContainerProps> = ({ 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<PopupContainerProps> = ({ 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<PopupContainerProps> = ({ model, resolve }) => {
</SearchIcon>
}
ref={inputRef}
placeholder={t('model.search')}
placeholder={t('models.search')}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear

View File

@ -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 = () => {
</StyledLink>
</Tooltip>
)}
<Tooltip title={t('knowledge_base.title')} mouseEnterDelay={0.5} placement="right">
<StyledLink onClick={() => to('/knowledge')}>
<Icon className={isRoute('/knowledge')}>
<FileSearchOutlined />
</Icon>
</StyledLink>
</Tooltip>
{showFilesIcon && (
<Tooltip title={t('files.title')} mouseEnterDelay={0.8} placement="right">
<StyledLink onClick={() => to('/files')}>

View File

@ -221,7 +221,8 @@ const _apps: MinAppType[] = [
id: 'thinkany',
name: 'ThinkAny',
logo: ThinkAnyLogo,
url: 'https://thinkany.ai/'
url: 'https://thinkany.ai/',
bodered: true
}
]

View File

@ -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) {

View File

@ -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}
`

View File

@ -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'
}

View File

@ -31,6 +31,13 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
Menu: {
activeBarBorderWidth: 0,
darkItemBg: 'transparent'
},
Button: {
boxShadow: 'none',
boxShadowSecondary: 'none',
defaultShadow: 'none',
dangerShadow: 'none',
primaryShadow: 'none'
}
},
token: {

View File

@ -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<FileType, 'id'>
topics: EntityTable<Pick<Topic, 'id' | 'messages'>, 'id'>
settings: EntityTable<{ id: string; value: any }, 'id'>
knowledge_notes: EntityTable<KnowledgeItem, 'id'>
}
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

View File

@ -73,4 +73,8 @@ export function useAppInit() {
dispatch(setFilesPath(info.filesPath))
})
}, [dispatch])
useEffect(() => {
import('@renderer/queue/KnowledgeQueue')
}, [])
}

View File

@ -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<KnowledgeItem[]>([])
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
}
}

View File

@ -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
}
)
}

View File

@ -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"
}
}
}

View File

@ -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": "Встраиваемые модели"
}
}
}

View File

@ -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": "嵌入模型"
}
}
}

View File

@ -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": "嵌入模型"
}
}
}

View File

@ -11,9 +11,10 @@ interface Props {
files: FileType[]
setFiles: (files: FileType[]) => void
ToolbarButton: any
disabled?: boolean
}
const AttachmentButton: FC<Props> = ({ model, files, setFiles, ToolbarButton }) => {
const AttachmentButton: FC<Props> = ({ model, files, setFiles, ToolbarButton, disabled }) => {
const { t } = useTranslation()
const extensions = isVisionModel(model)
? [...imageExts, ...documentExts, ...textExts]
@ -37,7 +38,7 @@ const AttachmentButton: FC<Props> = ({ model, files, setFiles, ToolbarButton })
return (
<Tooltip placement="top" title={t('chat.input.upload')} arrow>
<ToolbarButton type="text" className={files.length ? 'active' : ''} onClick={onSelectFile}>
<ToolbarButton type="text" className={files.length ? 'active' : ''} onClick={onSelectFile} disabled={disabled}>
<PaperClipOutlined style={{ rotate: '135deg' }} />
</ToolbarButton>
</Tooltip>

View File

@ -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<Props> = ({ assistant: _assistant, setActiveTopic }) => {
const [text, setText] = useState(_text)
@ -78,6 +80,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
const [spaceClickCount, setSpaceClickCount] = useState(0)
const spaceClickTimer = useRef<NodeJS.Timeout>()
const [isTranslating, setIsTranslating] = useState(false)
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | undefined>(_base)
const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
@ -90,6 +93,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
_text = text
_files = files
_base = selectedKnowledgeBase
const sendMessage = useCallback(async () => {
if (generating) {
@ -111,6 +115,10 @@ const Inputbar: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ assistant: _assistant, setActiveTopic }) => {
const textareaRows = window.innerHeight >= 1000 || isBubbleStyle ? 2 : 1
const handleKnowledgeBaseSelect = (base?: KnowledgeBase) => {
setSelectedKnowledgeBase(base)
}
return (
<Container onDragOver={handleDragOver} onDrop={handleDrop}>
<AttachmentPreview files={files} setFiles={setFiles} />
@ -438,7 +450,19 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
<ControlOutlined />
</ToolbarButton>
</Tooltip>
<AttachmentButton model={model} files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} />
<KnowledgeBaseButton
selectedBase={selectedKnowledgeBase}
onSelect={handleKnowledgeBaseSelect}
ToolbarButton={ToolbarButton}
disabled={files.length > 0}
/>
<AttachmentButton
model={model}
files={files}
setFiles={setFiles}
ToolbarButton={ToolbarButton}
disabled={!!selectedKnowledgeBase}
/>
<ToolbarButton type="text" onClick={onNewContext}>
<Tooltip placement="top" title={t('chat.input.new.context')}>
<PicCenterOutlined />

View File

@ -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<Props> = ({ selectedBase, onSelect }) => {
const { t } = useTranslation()
const knowledgeState = useAppSelector((state) => state.knowledge)
return (
<SelectorContainer>
{knowledgeState.bases.length === 0 ? (
<EmptyMessage>{t('knowledge.no_bases')}</EmptyMessage>
) : (
<>
{selectedBase && (
<Button type="link" block onClick={() => onSelect(undefined)} style={{ textAlign: 'left' }}>
{t('knowledge.clear_selection')}
</Button>
)}
{knowledgeState.bases.map((base) => (
<Button
key={base.id}
type={selectedBase?.id === base.id ? 'primary' : 'text'}
block
onClick={() => onSelect(base)}
style={{ textAlign: 'left' }}>
{base.name}
</Button>
))}
</>
)}
</SelectorContainer>
)
}
const KnowledgeBaseButton: FC<Props> = ({ selectedBase, onSelect, disabled, ToolbarButton }) => {
const { t } = useTranslation()
if (selectedBase) {
return (
<Tooltip placement="top" title={t('chat.input.knowledge_base')} arrow>
<ToolbarButton type="text" onClick={() => onSelect(undefined)}>
<FileSearchOutlined style={{ color: selectedBase ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Tooltip>
)
}
return (
<Tooltip placement="top" title={t('chat.input.knowledge_base')} arrow>
<Popover
placement="top"
content={<KnowledgeBaseSelector selectedBase={selectedBase} onSelect={onSelect} />}
trigger="click">
<ToolbarButton type="text" onClick={() => selectedBase && onSelect(undefined)} disabled={disabled}>
<FileSearchOutlined style={{ color: selectedBase ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Popover>
</Tooltip>
)
}
const SelectorContainer = styled.div`
max-height: 300px;
overflow-y: auto;
`
const EmptyMessage = styled.div`
padding: 8px;
`
export default KnowledgeBaseButton

View File

@ -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 (
<MessageMetadata className="message-tokens" onClick={locateMessage}>
{metrixs !== '' ? metrixs : ''}
Tokens: {message?.usage?.total_tokens} {message?.usage?.prompt_tokens} {message?.usage?.completion_tokens}
<MessageMetadata className={`message-tokens ${hasMetrics ? 'has-metrics' : ''}`} onClick={locateMessage}>
<span className="metrics">{metrixs}</span>
<span className="tokens">
Tokens: {message?.usage?.total_tokens} {message?.usage?.prompt_tokens} {message?.usage?.completion_tokens}
</span>
</MessageMetadata>
)
}
@ -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

View File

@ -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<Props> = ({ activeAssistant }) => {
<NavbarIcon onClick={toggleShowAssistants} style={{ marginLeft: isMac ? 8 : 0 }}>
<i className="iconfont icon-hide-sidebar" />
</NavbarIcon>
<NavbarIcon onClick={() => SearchPopup.show()}>
<SearchOutlined />
<NavbarIcon onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
<FormOutlined />
</NavbarIcon>
</NavbarLeft>
)}
@ -70,6 +70,9 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
<SelectModelButton assistant={assistant} />
</HStack>
<HStack alignItems="center">
<NavbarIcon onClick={() => SearchPopup.show()}>
<SearchOutlined />
</NavbarIcon>
<AppStorePopover>
<NavbarIcon>
<i className="iconfont icon-appstore" />

View File

@ -162,7 +162,7 @@ const SettingsTab: FC<Props> = (props) => {
</Col>
</Row>
<SettingRow>
<SettingRowTitleSmall>{t('model.stream_output')}</SettingRowTitleSmall>
<SettingRowTitleSmall>{t('models.stream_output')}</SettingRowTitleSmall>
<Switch
size="small"
checked={streamOutput}

View File

@ -0,0 +1,419 @@
import {
DeleteOutlined,
EditOutlined,
FileTextOutlined,
FolderOutlined,
GlobalOutlined,
LinkOutlined,
PlusOutlined,
SearchOutlined
} from '@ant-design/icons'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
import Scrollbar from '@renderer/components/Scrollbar'
import { useKnowledge } from '@renderer/hooks/useKnowledge'
import FileManager from '@renderer/services/FileManager'
import { FileType, FileTypes, KnowledgeBase } from '@renderer/types'
import { Button, Card, message, Typography, Upload } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import KnowledgeSearchPopup from './components/KnowledgeSearchPopup'
import StatusIcon from './components/StatusIcon'
const { Dragger } = Upload
const { Title } = Typography
interface KnowledgeContentProps {
selectedBase: KnowledgeBase
}
const fileTypes = ['.pdf', '.docx', '.pptx', '.xlsx', '.txt', '.md', '.mdx']
const FlexColumn = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`
const FlexAlignCenter = styled.div`
display: flex;
align-items: center;
gap: 16px;
`
const ClickableSpan = styled.span`
cursor: pointer;
`
const FileIcon = styled(FileTextOutlined)`
font-size: 16px;
`
const BottomSpacer = styled.div`
min-height: 20px;
`
const KnowledgeContent: FC<KnowledgeContentProps> = ({ 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 (
<MainContent>
<FileSection>
<TitleWrapper>
<Title level={5}>{t('files.title')}</Title>
<Button icon={<PlusOutlined />} onClick={handleAddFile}>
{t('knowledge_base.add_file')}
</Button>
</TitleWrapper>
<Dragger
showUploadList={false}
customRequest={({ file }) => handleDrop([file as File])}
multiple={true}
accept={fileTypes.join(',')}
style={{ marginTop: 10, background: 'transparent' }}>
<p className="ant-upload-text">{t('knowledge_base.drag_file')}</p>
<p className="ant-upload-hint">
{t('knowledge_base.file_hint', { file_types: fileTypes.join(', ').replaceAll('.', '') })}
</p>
</Dragger>
</FileSection>
<FileListSection>
{fileItems.map((item) => {
const file = item.content as FileType
return (
<ItemCard key={item.id}>
<ItemContent>
<ItemInfo>
<FileIcon />
<ClickableSpan onClick={() => window.api.file.openPath(file.path)}>{file.origin_name}</ClickableSpan>
</ItemInfo>
<FlexAlignCenter>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} />
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
</ItemContent>
</ItemCard>
)
})}
</FileListSection>
<ContentSection>
<TitleWrapper>
<Title level={5}>{t('knowledge_base.directories')}</Title>
<Button icon={<PlusOutlined />} onClick={handleAddDirectory}>
{t('knowledge_base.add_directory')}
</Button>
</TitleWrapper>
<FlexColumn>
{directoryItems.map((item) => (
<ItemCard key={item.id}>
<ItemContent>
<ItemInfo>
<FolderOutlined />
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
{item.content as string}
</ClickableSpan>
</ItemInfo>
<FlexAlignCenter>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} />
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
</ItemContent>
</ItemCard>
))}
</FlexColumn>
</ContentSection>
<ContentSection>
<TitleWrapper>
<Title level={5}>{t('knowledge_base.urls')}</Title>
<Button icon={<PlusOutlined />} onClick={handleAddUrl}>
{t('knowledge_base.add_url')}
</Button>
</TitleWrapper>
<FlexColumn>
{urlItems.map((item) => (
<ItemCard key={item.id}>
<ItemContent>
<ItemInfo>
<LinkOutlined />
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
{item.content as string}
</a>
</ItemInfo>
<FlexAlignCenter>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} />
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
</ItemContent>
</ItemCard>
))}
</FlexColumn>
</ContentSection>
<ContentSection>
<TitleWrapper>
<Title level={5}>{t('knowledge_base.sitemaps')}</Title>
<Button icon={<PlusOutlined />} onClick={handleAddSitemap}>
{t('knowledge_base.add_sitemap')}
</Button>
</TitleWrapper>
<FlexColumn>
{sitemapItems.map((item) => (
<ItemCard key={item.id}>
<ItemContent>
<ItemInfo>
<GlobalOutlined />
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
{item.content as string}
</a>
</ItemInfo>
<FlexAlignCenter>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} />
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
</ItemContent>
</ItemCard>
))}
</FlexColumn>
</ContentSection>
<ContentSection>
<TitleWrapper>
<Title level={5}>{t('knowledge_base.notes')}</Title>
<Button icon={<PlusOutlined />} onClick={handleAddNote}>
{t('knowledge_base.add_note')}
</Button>
</TitleWrapper>
<FlexColumn>
{noteItems.map((note) => (
<ItemCard key={note.id}>
<ItemContent>
<ItemInfo onClick={() => handleEditNote(note)} style={{ cursor: 'pointer' }}>
<span>{(note.content as string).slice(0, 50)}...</span>
</ItemInfo>
<FlexAlignCenter>
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
<StatusIcon sourceId={note.id} base={base} getProcessingStatus={getProcessingStatus} />
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
</ItemContent>
</ItemCard>
))}
</FlexColumn>
</ContentSection>
<IndexSection>
<Button type="primary" onClick={() => KnowledgeSearchPopup.show({ base })} icon={<SearchOutlined />}>
{t('knowledge_base.search')}
</Button>
</IndexSection>
<BottomSpacer />
</MainContent>
)
}
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

View File

@ -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<KnowledgeBase>()
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: <EditOutlined />,
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: <DeleteOutlined />,
onClick: () => {
window.modal.confirm({
title: t('knowledge_base.delete_confirm'),
centered: true,
onOk: () => {
deleteKnowledgeBase(base.id)
}
})
}
}
]
return menus
},
[deleteKnowledgeBase, renameKnowledgeBase, t]
)
return (
<Container>
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('knowledge_base.title')}</NavbarCenter>
</Navbar>
<ContentContainer id="content-container">
<SideNav>
<ScrollContainer>
<DragableList
list={bases}
onUpdate={updateKnowledgeBases}
style={{ marginBottom: 0, paddingBottom: isDragging ? 50 : 0 }}
onDragStart={() => setIsDragging(true)}
onDragEnd={() => setIsDragging(false)}>
{(base) => (
<Dropdown menu={{ items: getMenuItems(base) }} trigger={['contextMenu']} key={base.id}>
<div>
<ListItem
active={selectedBase?.id === base.id}
icon={<FileTextOutlined />}
title={base.name}
onClick={() => setSelectedBase(base)}
/>
</div>
</Dropdown>
)}
</DragableList>
{!isDragging && (
<AddKnowledgeItem onClick={handleAddKnowledge}>
<AddKnowledgeName>
<PlusOutlined style={{ color: 'var(--color-text-2)', marginRight: 4 }} />
{t('button.add')}
</AddKnowledgeName>
</AddKnowledgeItem>
)}
<div style={{ minHeight: '10px' }}></div>
</ScrollContainer>
</SideNav>
{bases.length === 0 ? (
<MainContent>
<Empty description={t('knowledge_base.empty')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</MainContent>
) : selectedBase ? (
<KnowledgeContent selectedBase={selectedBase} />
) : null}
</ContentContainer>
</Container>
)
}
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

View File

@ -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<Props> = ({ title, resolve }) => {
const [open, setOpen] = useState(true)
const [form] = Form.useForm<FormData>()
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 (
<Modal title={title} open={open} onOk={onOk} onCancel={onCancel} afterClose={onClose} destroyOnClose centered>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label={t('common.name')}
rules={[{ required: true, message: t('message.error.enter.name') }]}>
<Input placeholder={t('common.name')} />
</Form.Item>
<Form.Item
name="model"
label={t('common.model')}
rules={[{ required: true, message: t('message.error.enter.model') }]}>
<Select style={{ width: '100%' }} options={selectOptions} placeholder={t('settings.models.empty')} />
</Form.Item>
</Form>
</Modal>
)
}
export default class AddKnowledgePopup {
static hide() {
TopView.hide('AddKnowledgePopup')
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
this.hide()
}}
/>,
'AddKnowledgePopup'
)
})
}
}

View File

@ -0,0 +1,182 @@
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { TopView } from '@renderer/components/TopView'
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import { KnowledgeBase } from '@renderer/types'
import { Input, List, Modal, Spin, Typography } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const { Search } = Input
const { Text, Paragraph } = Typography
interface ShowParams {
base: KnowledgeBase
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ base, resolve }) => {
const [open, setOpen] = useState(true)
const [loading, setLoading] = useState(false)
const [results, setResults] = useState<ExtractChunkData[]>([])
const [searchKeyword, setSearchKeyword] = useState('')
const { t } = useTranslation()
const handleSearch = async (value: string) => {
if (!value.trim()) {
setResults([])
setSearchKeyword('')
return
}
setSearchKeyword(value.trim())
setLoading(true)
try {
const searchResults = await window.api.knowledgeBase.search({
search: value,
base: getKnowledgeBaseParams(base)
})
setResults(searchResults)
} catch (error) {
console.error('Search failed:', error)
} finally {
setLoading(false)
}
}
const onOk = () => {
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
KnowledgeSearchPopup.hide = onCancel
const highlightText = (text: string) => {
if (!searchKeyword) return text
const parts = text.split(new RegExp(`(${searchKeyword})`, 'gi'))
return parts.map((part, i) =>
part.toLowerCase() === searchKeyword.toLowerCase() ? <mark key={i}>{part}</mark> : part
)
}
return (
<Modal
title={t('knowledge_base.search')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
width={800}
footer={null}
centered
transitionName="ant-move-down">
<SearchContainer>
<Search
placeholder={t('knowledge_base.search_placeholder')}
allowClear
enterButton
size="large"
onSearch={handleSearch}
/>
<ResultsContainer>
{loading ? (
<LoadingContainer>
<Spin size="large" />
</LoadingContainer>
) : (
<List
dataSource={results}
renderItem={(item) => (
<List.Item>
<ResultItem>
<ScoreTag>Score: {(item.score * 100).toFixed(1)}%</ScoreTag>
<Paragraph>{highlightText(item.pageContent)}</Paragraph>
<MetadataContainer>
<Text type="secondary">Source: {item.metadata.source}</Text>
</MetadataContainer>
</ResultItem>
</List.Item>
)}
/>
)}
</ResultsContainer>
</SearchContainer>
</Modal>
)
}
const SearchContainer = styled.div`
display: flex;
flex-direction: column;
gap: 20px;
`
const ResultsContainer = styled.div`
max-height: 60vh;
overflow-y: auto;
`
const LoadingContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 200px;
`
const ResultItem = styled.div`
width: 100%;
position: relative;
padding: 16px;
background: var(--color-background-soft);
border-radius: 8px;
`
const ScoreTag = styled.div`
position: absolute;
top: 8px;
right: 8px;
padding: 2px 8px;
background: var(--color-primary);
color: white;
border-radius: 4px;
font-size: 12px;
`
const MetadataContainer = styled.div`
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--color-border);
`
const TopViewKey = 'KnowledgeSearchPopup'
export default class KnowledgeSearchPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@ -0,0 +1,90 @@
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
import { Center } from '@renderer/components/Layout'
import { KnowledgeBase, ProcessingStatus } from '@renderer/types'
import { Tooltip } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface StatusIconProps {
sourceId: string
base: KnowledgeBase
getProcessingStatus: (sourceId: string) => ProcessingStatus | undefined
}
const StatusIcon: FC<StatusIconProps> = ({ sourceId, base, getProcessingStatus }) => {
const { t } = useTranslation()
const status = getProcessingStatus(sourceId)
const item = base.items.find((item) => item.id === sourceId)
const errorText = item?.processingError
if (!status) {
if (item?.uniqueId) {
return (
<Tooltip title={t('knowledge_base.status_completed')} placement="left">
<CheckCircleOutlined style={{ color: '#52c41a' }} />
</Tooltip>
)
}
return (
<Tooltip title={t('knowledge_base.status_new')} placement="left">
<Center style={{ width: '16px', height: '16px' }}>
<StatusDot $status="new" />
</Center>
</Tooltip>
)
}
switch (status) {
case 'pending':
return (
<Tooltip title={t('knowledge_base.status_pending')} placement="left">
<StatusDot $status="pending" />
</Tooltip>
)
case 'processing':
return (
<Tooltip title={t('knowledge_base.status_processing')} placement="left">
<StatusDot $status="processing" />
</Tooltip>
)
case 'completed':
return (
<Tooltip title={t('knowledge_base.status_completed')} placement="left">
<CheckCircleOutlined style={{ color: '#52c41a' }} />
</Tooltip>
)
case 'failed':
return (
<Tooltip title={errorText || t('knowledge_base.status_failed')} placement="left">
<CloseCircleOutlined style={{ color: '#ff4d4f' }} />
</Tooltip>
)
default:
return null
}
}
const StatusDot = styled.div<{ $status: 'pending' | 'processing' | 'new' }>`
width: 8px;
height: 8px;
border-radius: 50%;
background-color: ${(props) =>
props.$status === 'pending' ? '#faad14' : props.$status === 'new' ? '#918999' : '#1890ff'};
animation: ${(props) => (props.$status === 'processing' ? 'pulse 2s infinite' : 'none')};
cursor: pointer;
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.4;
}
100% {
opacity: 1;
}
}
`
export default StatusIcon

View File

@ -1,4 +1,4 @@
import { GithubOutlined, TwitterOutlined } from '@ant-design/icons'
import { GithubOutlined, XOutlined } from '@ant-design/icons'
import { FileProtectOutlined, GlobalOutlined, MailOutlined, SendOutlined, SoundOutlined } from '@ant-design/icons'
import IndicatorLight from '@renderer/components/IndicatorLight'
import { HStack } from '@renderer/components/Layout'
@ -208,7 +208,7 @@ const AboutSettings: FC = () => {
<SettingDivider />
<SettingRow>
<SettingRowTitle>
<TwitterOutlined />X
<XOutlined />X
</SettingRowTitle>
<Button onClick={() => onOpenWebsite('https://x.com/kangfenmao')}>
{t('settings.about.website.button')}

View File

@ -1,12 +1,13 @@
import { LoadingOutlined, MinusOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'
import VisionIcon from '@renderer/components/Icons/VisionIcon'
import WebSearchIcon from '@renderer/components/Icons/WebSearchIcon'
import { getModelLogo, isVisionModel, isWebSearchModel, SYSTEM_MODELS } from '@renderer/config/models'
import { Center } from '@renderer/components/Layout'
import { getModelLogo, isEmbeddingModel, isVisionModel, isWebSearchModel, SYSTEM_MODELS } from '@renderer/config/models'
import { useProvider } from '@renderer/hooks/useProvider'
import { fetchModels } from '@renderer/services/ApiService'
import { Model, Provider } from '@renderer/types'
import { getDefaultGroupName, isFreeModel, runAsyncFunction } from '@renderer/utils'
import { Avatar, Button, Empty, Flex, Modal, Popover, Tag } from 'antd'
import { Avatar, Button, Empty, Flex, Modal, Popover, Radio, Tag } from 'antd'
import Search from 'antd/es/input/Search'
import { groupBy, isEmpty, uniqBy } from 'lodash'
import { useEffect, useState } from 'react'
@ -29,14 +30,29 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
const [listModels, setListModels] = useState<Model[]>([])
const [loading, setLoading] = useState(false)
const [searchText, setSearchText] = useState('')
const { t } = useTranslation()
const [filterType, setFilterType] = useState<string>('all')
const { t, i18n } = useTranslation()
const systemModels = SYSTEM_MODELS[_provider.id] || []
const allModels = uniqBy([...systemModels, ...listModels, ...models], 'id')
const list = searchText
? allModels.filter((model) => model.id.toLocaleLowerCase().includes(searchText.toLocaleLowerCase()))
: allModels
const list = allModels.filter((model) => {
if (searchText && !model.id.toLocaleLowerCase().includes(searchText.toLocaleLowerCase())) {
return false
}
switch (filterType) {
case 'vision':
return isVisionModel(model)
case 'websearch':
return isWebSearchModel(model)
case 'free':
return isFreeModel(model)
case 'embedding':
return isEmbeddingModel(model)
default:
return true
}
})
const modelGroups = groupBy(list, 'group')
@ -89,7 +105,9 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
return (
<Flex>
<ModelHeaderTitle>
{provider.isSystem ? t(`provider.${provider.id}`) : provider.name} {t('common.models')}
{provider.isSystem ? t(`provider.${provider.id}`) : provider.name}
{i18n.language.startsWith('zh') ? '' : ' '}
{t('common.models')}
</ModelHeaderTitle>
{loading && <LoadingOutlined size={20} />}
</Flex>
@ -111,6 +129,15 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
}}
centered>
<SearchContainer>
<Center>
<Radio.Group value={filterType} onChange={(e) => setFilterType(e.target.value)} buttonStyle="solid">
<Radio.Button value="all">{t('models.all')}</Radio.Button>
<Radio.Button value="vision">{t('models.vision')}</Radio.Button>
<Radio.Button value="websearch">{t('models.websearch')}</Radio.Button>
<Radio.Button value="free">{t('models.free')}</Radio.Button>
<Radio.Button value="embedding">{t('models.embedding')}</Radio.Button>
</Radio.Group>
</Center>
<Search placeholder={t('settings.provider.search_placeholder')} allowClear onSearch={setSearchText} />
</SearchContainer>
<ListContainer>
@ -131,7 +158,12 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
{isWebSearchModel(model) && <WebSearchIcon />}
{isFreeModel(model) && (
<Tag style={{ marginLeft: 10 }} color="green">
Free
{t('models.free')}
</Tag>
)}
{isEmbeddingModel(model) && (
<Tag style={{ marginLeft: 10 }} color="orange">
{t('models.embedding')}
</Tag>
)}
{!isEmpty(model.description) && (
@ -163,11 +195,16 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
const SearchContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-direction: column;
gap: 12px;
padding: 0 22px;
padding-bottom: 20px;
margin-top: -10px;
.ant-radio-group {
display: flex;
flex-wrap: wrap;
}
`
const ListContainer = styled.div`

View File

@ -9,7 +9,7 @@ import {
} from '@ant-design/icons'
import VisionIcon from '@renderer/components/Icons/VisionIcon'
import WebSearchIcon from '@renderer/components/Icons/WebSearchIcon'
import { getModelLogo, isVisionModel, isWebSearchModel, VISION_REGEX } from '@renderer/config/models'
import { EMBEDDING_REGEX, getModelLogo, isVisionModel, isWebSearchModel, VISION_REGEX } from '@renderer/config/models'
import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant'
@ -165,7 +165,10 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
<Checkbox.Group
value={model.type}
onChange={(types) => onUpdateModelTypes(model, types as ModelType[])}
options={[{ label: t('model.type.vision'), value: 'vision', disabled: VISION_REGEX.test(model.id) }]}
options={[
{ label: t('models.type.vision'), value: 'vision', disabled: VISION_REGEX.test(model.id) },
{ label: t('models.type.embedding'), value: 'embedding', disabled: EMBEDDING_REGEX.test(model.id) }
]}
/>
</div>
)
@ -270,7 +273,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
</Avatar>
{model.name} {isVisionModel(model) && <VisionIcon />}
{isWebSearchModel(model) && <WebSearchIcon />}
<Popover content={modelTypeContent(model)} title={t('model.type.select')} trigger="click">
<Popover content={modelTypeContent(model)} title={t('models.type.select')} trigger="click">
<SettingIcon />
</Popover>
</ModelListHeader>

View File

@ -3,6 +3,8 @@ import ProviderFactory from '@renderer/providers/ProviderFactory'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types'
import OpenAI from 'openai'
import { CompletionsParams } from '.'
export default class AiProvider {
private sdk: BaseProvider
@ -42,6 +44,10 @@ export default class AiProvider {
return this.sdk.models()
}
public getApiKey(): string {
return this.sdk.getApiKey()
}
public async generateImage(params: {
prompt: string
negativePrompt: string

View File

@ -1,14 +1,16 @@
import Anthropic from '@anthropic-ai/sdk'
import { MessageCreateParamsNonStreaming, MessageParam } from '@anthropic-ai/sdk/resources'
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import { isEmbeddingModel } from '@renderer/config/models'
import { SUMMARIZE_PROMPT } from '@renderer/config/prompts'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
import { EVENT_NAMES } from '@renderer/services/EventService'
import { filterContextMessages } from '@renderer/services/MessagesService'
import { Assistant, FileTypes, Message, Provider, Suggestion } from '@renderer/types'
import { first, flatten, sum, takeRight } from 'lodash'
import { first, flatten, last, sum, takeRight } from 'lodash'
import OpenAI from 'openai'
import { CompletionsParams } from '.'
import BaseProvider from './BaseProvider'
export default class AnthropicProvider extends BaseProvider {
@ -24,7 +26,12 @@ export default class AnthropicProvider extends BaseProvider {
}
private async getMessageParam(message: Message): Promise<MessageParam> {
const parts: MessageParam['content'] = [{ type: 'text', text: message.content }]
const parts: MessageParam['content'] = [
{
type: 'text',
text: await this.getMessageContent(message)
}
]
for (const file of message.files || []) {
if (file.type === FileTypes.IMAGE) {
@ -82,11 +89,20 @@ export default class AnthropicProvider extends BaseProvider {
system: assistant.prompt
}
let time_first_token_millsec = 0
const start_time_millsec = new Date().getTime()
if (!streamOutput) {
const message = await this.sdk.messages.create({ ...body, stream: false })
const time_completion_millsec = new Date().getTime() - start_time_millsec
return onChunk({
text: message.content[0].type === 'text' ? message.content[0].text : '',
usage: message.usage
usage: message.usage,
metrics: {
completion_tokens: message.usage.output_tokens,
time_completion_millsec,
time_first_token_millsec: 0
}
})
}
@ -98,7 +114,18 @@ export default class AnthropicProvider extends BaseProvider {
stream.controller.abort()
return resolve()
}
onChunk({ text })
if (time_first_token_millsec == 0) {
time_first_token_millsec = new Date().getTime() - start_time_millsec
}
const time_completion_millsec = new Date().getTime() - start_time_millsec
onChunk({
text,
metrics: {
completion_tokens: undefined,
time_completion_millsec,
time_first_token_millsec
}
})
})
.on('finalMessage', (message) => {
onChunk({
@ -107,6 +134,11 @@ export default class AnthropicProvider extends BaseProvider {
prompt_tokens: message.usage.input_tokens,
completion_tokens: message.usage.output_tokens,
total_tokens: sum(Object.values(message.usage))
},
metrics: {
completion_tokens: message.usage.output_tokens,
time_completion_millsec: new Date().getTime() - start_time_millsec,
time_first_token_millsec
}
})
resolve()
@ -203,7 +235,11 @@ export default class AnthropicProvider extends BaseProvider {
}
public async check(): Promise<{ valid: boolean; error: Error | null }> {
const model = this.provider.models[0]
const model = last(this.provider.models.filter((m) => !isEmbeddingModel(m)))
if (!model) {
return { valid: false, error: new Error('No model found') }
}
const body = {
model: model.id,

View File

@ -1,8 +1,14 @@
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
import { getOllamaKeepAliveTime } from '@renderer/hooks/useOllama'
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import store from '@renderer/store'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types'
import { delay } from '@renderer/utils'
import { take } from 'lodash'
import OpenAI from 'openai'
import { CompletionsParams } from '.'
export default abstract class BaseProvider {
protected provider: Provider
protected host: string
@ -14,6 +20,24 @@ export default abstract class BaseProvider {
this.apiKey = this.getApiKey()
}
abstract completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void>
abstract translate(message: Message, assistant: Assistant): Promise<string>
abstract summaries(messages: Message[], assistant: Assistant): Promise<string>
abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]>
abstract generateText({ prompt, content }: { prompt: string; content: string }): Promise<string>
abstract check(): Promise<{ valid: boolean; error: Error | null }>
abstract models(): Promise<OpenAI.Models.Model[]>
abstract generateImage(_params: {
prompt: string
negativePrompt: string
imageSize: string
batchSize: number
seed?: string
numInferenceSteps: number
guidanceScale: number
signal?: AbortSignal
}): Promise<string[]>
public getBaseURL(): string {
const host = this.provider.apiHost
return host.endsWith('/') ? host : `${host}/v1/`
@ -58,21 +82,37 @@ export default abstract class BaseProvider {
}
}
abstract completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void>
abstract translate(message: Message, assistant: Assistant): Promise<string>
abstract summaries(messages: Message[], assistant: Assistant): Promise<string>
abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]>
abstract generateText({ prompt, content }: { prompt: string; content: string }): Promise<string>
abstract check(): Promise<{ valid: boolean; error: Error | null }>
abstract models(): Promise<OpenAI.Models.Model[]>
abstract generateImage(_params: {
prompt: string
negativePrompt: string
imageSize: string
batchSize: number
seed?: string
numInferenceSteps: number
guidanceScale: number
signal?: AbortSignal
}): Promise<string[]>
public async getMessageContent(message: Message) {
if (!message.knowledgeBaseIds) {
return message.content
}
const knowledgeId = message.knowledgeBaseIds[0]
const base = store.getState().knowledge.bases.find((kb) => kb.id === knowledgeId)
if (!base) {
return message.content
}
const searchResults = await window.api.knowledgeBase.search({
search: message.content,
base: getKnowledgeBaseParams(base)
})
const references = take(searchResults, 6).map((item, index) => {
const sourceUrl = item.metadata.source
const baseItem = base.items.find((i) => i.uniqueId === item.metadata.uniqueLoaderId)
return {
id: index,
content: item.pageContent,
url: encodeURIComponent(sourceUrl),
type: baseItem?.type
}
})
const referencesContent = JSON.stringify(references, null, 2)
return REFERENCE_PROMPT.replace('{question}', message.content).replace('{references}', referencesContent)
}
}

View File

@ -8,15 +8,17 @@ import {
RequestOptions,
TextPart
} from '@google/generative-ai'
import { isEmbeddingModel } from '@renderer/config/models'
import { SUMMARIZE_PROMPT } from '@renderer/config/prompts'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
import { EVENT_NAMES } from '@renderer/services/EventService'
import { filterContextMessages } from '@renderer/services/MessagesService'
import { Assistant, FileTypes, Message, Provider, Suggestion } from '@renderer/types'
import axios from 'axios'
import { first, isEmpty, takeRight } from 'lodash'
import { first, isEmpty, last, takeRight } from 'lodash'
import OpenAI from 'openai'
import { CompletionsParams } from '.'
import BaseProvider from './BaseProvider'
export default class GeminiProvider extends BaseProvider {
@ -34,7 +36,7 @@ export default class GeminiProvider extends BaseProvider {
private async getMessageContents(message: Message): Promise<Content> {
const role = message.role === 'user' ? 'user' : 'model'
const parts: Part[] = [{ text: message.content }]
const parts: Part[] = [{ text: await this.getMessageContent(message) }]
for (const file of message.files || []) {
if (file.type === FileTypes.IMAGE) {
@ -107,29 +109,47 @@ export default class GeminiProvider extends BaseProvider {
const chat = geminiModel.startChat({ history })
const messageContents = await this.getMessageContents(userLastMessage!)
const start_time_millsec = new Date().getTime()
if (!streamOutput) {
const { response } = await chat.sendMessage(messageContents.parts)
const time_completion_millsec = new Date().getTime() - start_time_millsec
onChunk({
text: response.candidates?.[0].content.parts[0].text,
usage: {
prompt_tokens: response.usageMetadata?.promptTokenCount || 0,
completion_tokens: response.usageMetadata?.candidatesTokenCount || 0,
total_tokens: response.usageMetadata?.totalTokenCount || 0
},
metrics: {
completion_tokens: response.usageMetadata?.candidatesTokenCount,
time_completion_millsec,
time_first_token_millsec: 0
}
})
return
}
const userMessagesStream = await chat.sendMessageStream(messageContents.parts)
let time_first_token_millsec = 0
for await (const chunk of userMessagesStream.stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break
if (time_first_token_millsec == 0) {
time_first_token_millsec = new Date().getTime() - start_time_millsec
}
const time_completion_millsec = new Date().getTime() - start_time_millsec
onChunk({
text: chunk.text(),
usage: {
prompt_tokens: chunk.usageMetadata?.promptTokenCount || 0,
completion_tokens: chunk.usageMetadata?.candidatesTokenCount || 0,
total_tokens: chunk.usageMetadata?.totalTokenCount || 0
},
metrics: {
completion_tokens: chunk.usageMetadata?.candidatesTokenCount,
time_completion_millsec,
time_first_token_millsec
}
})
}
@ -221,7 +241,11 @@ export default class GeminiProvider extends BaseProvider {
}
public async check(): Promise<{ valid: boolean; error: Error | null }> {
const model = this.provider.models[0]
const model = last(this.provider.models.filter((m) => !isEmbeddingModel(m)))
if (!model) {
return { valid: false, error: new Error('No model found') }
}
const body = {
model: model.id,

View File

@ -1,11 +1,11 @@
import { isSupportedModel, isVisionModel } from '@renderer/config/models'
import { isEmbeddingModel, isSupportedModel, isVisionModel } from '@renderer/config/models'
import { SUMMARIZE_PROMPT } from '@renderer/config/prompts'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
import { EVENT_NAMES } from '@renderer/services/EventService'
import { filterContextMessages } from '@renderer/services/MessagesService'
import { Assistant, FileTypes, Message, Model, Provider, Suggestion } from '@renderer/types'
import { removeQuotes } from '@renderer/utils'
import { takeRight } from 'lodash'
import { last, takeRight } from 'lodash'
import OpenAI, { AzureOpenAI } from 'openai'
import {
ChatCompletionContentPart,
@ -13,6 +13,7 @@ import {
ChatCompletionMessageParam
} from 'openai/resources'
import { CompletionsParams } from '.'
import BaseProvider from './BaseProvider'
export default class OpenAIProvider extends BaseProvider {
@ -49,11 +50,12 @@ export default class OpenAIProvider extends BaseProvider {
model: Model
): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam> {
const isVision = isVisionModel(model)
const content = await this.getMessageContent(message)
if (!message.files) {
return {
role: message.role,
content: message.content
content
}
}
@ -73,21 +75,21 @@ export default class OpenAIProvider extends BaseProvider {
return {
role: message.role,
content: message.content + divider + text
content: content + divider + text
}
}
}
return {
role: message.role,
content: message.content
content
}
}
const parts: ChatCompletionContentPart[] = [
{
type: 'text',
text: message.content
text: content
}
]
@ -149,19 +151,18 @@ export default class OpenAIProvider extends BaseProvider {
})
if (!isSupportStreamOutput) {
let time_completion_millsec = new Date().getTime() - start_time_millsec
const time_completion_millsec = new Date().getTime() - start_time_millsec
return onChunk({
text: stream.choices[0].message?.content || '',
usage: stream.usage,
metrics: {
completion_tokens: stream.usage?.completion_tokens,
time_completion_millsec: time_completion_millsec,
time_first_token_sec: 0,
time_completion_millsec,
time_first_token_millsec: 0
}
})
}
for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
break
@ -169,14 +170,14 @@ export default class OpenAIProvider extends BaseProvider {
if (time_first_token_millsec == 0) {
time_first_token_millsec = new Date().getTime() - start_time_millsec
}
let time_completion_millsec = new Date().getTime() - start_time_millsec
const time_completion_millsec = new Date().getTime() - start_time_millsec
onChunk({
text: chunk.choices[0]?.delta?.content || '',
usage: chunk.usage,
metrics: {
completion_tokens: chunk.usage?.completion_tokens,
time_completion_millsec: time_completion_millsec,
time_first_token_millsec: time_first_token_millsec,
time_completion_millsec,
time_first_token_millsec
}
})
}
@ -276,7 +277,11 @@ export default class OpenAIProvider extends BaseProvider {
}
public async check(): Promise<{ valid: boolean; error: Error | null }> {
const model = this.provider.models[0]
const model = last(this.provider.models.filter((m) => !isEmbeddingModel(m)))
if (!model) {
return { valid: false, error: new Error('No model found') }
}
const body = {
model: model.id,
@ -302,13 +307,7 @@ export default class OpenAIProvider extends BaseProvider {
public async models(): Promise<OpenAI.Models.Model[]> {
try {
const query: Record<string, any> = {}
if (this.provider.id === 'silicon') {
query.type = 'text'
}
const response = await this.sdk.models.list({ query })
const response = await this.sdk.models.list()
if (this.provider.id === 'github') {
// @ts-ignore key is not typed

View File

@ -0,0 +1,207 @@
import type { AddLoaderReturn } from '@llm-tools/embedjs-interfaces'
import db from '@renderer/databases'
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import store from '@renderer/store'
import { clearCompletedProcessing, updateBaseItemUniqueId, updateItemProcessingStatus } from '@renderer/store/knowledge'
import { KnowledgeItem } from '@renderer/types'
class KnowledgeQueue {
private processing: Map<string, boolean> = new Map()
private pollingInterval: NodeJS.Timeout | null = null
// private readonly POLLING_INTERVAL = 5000
private readonly MAX_RETRIES = 3
constructor() {
this.checkAllBases().catch(console.error)
this.startPolling()
}
private startPolling(): void {
if (this.pollingInterval) return
const state = store.getState()
state.knowledge.bases.forEach((base) => {
base.items.forEach((item) => {
if (item.processingStatus === 'processing') {
store.dispatch(
updateItemProcessingStatus({
baseId: base.id,
itemId: item.id,
status: 'pending',
progress: 0
})
)
}
})
})
// this.pollingInterval = setInterval(() => {
// this.checkAllBases()
// }, this.POLLING_INTERVAL)
}
private stopPolling(): void {
if (this.pollingInterval) {
clearInterval(this.pollingInterval)
this.pollingInterval = null
}
}
public async checkAllBases(): Promise<void> {
const state = store.getState()
const bases = state.knowledge.bases
await Promise.all(
bases.map(async (base) => {
const processableItems = base.items.filter((item) => {
if (item.processingStatus === 'failed') {
return !item.retryCount || item.retryCount < this.MAX_RETRIES
}
return item.processingStatus === 'pending'
})
const hasProcessableItems = processableItems.length > 0
if (hasProcessableItems && !this.processing.get(base.id)) {
await this.processQueue(base.id)
}
})
)
}
async processQueue(baseId: string): Promise<void> {
if (this.processing.get(baseId)) {
console.log(`[KnowledgeQueue] Queue for base ${baseId} is already being processed`)
return
}
this.processing.set(baseId, true)
try {
const state = store.getState()
const base = state.knowledge.bases.find((b) => b.id === baseId)
if (!base) {
throw new Error('Knowledge base not found')
}
const processableItems = base.items.filter((item) => {
if (item.processingStatus === 'failed') {
return !item.retryCount || item.retryCount < this.MAX_RETRIES
}
return item.processingStatus === 'pending'
})
for (const item of processableItems) {
if (!this.processing.get(baseId)) {
console.log(`[KnowledgeQueue] Processing interrupted for base ${baseId}`)
break
}
this.processItem(baseId, item)
}
} finally {
console.log(`[KnowledgeQueue] Finished processing queue for base ${baseId}`)
this.processing.set(baseId, false)
}
}
stopProcessing(baseId: string): void {
this.processing.set(baseId, false)
}
stopAllProcessing(): void {
this.stopPolling()
for (const baseId of this.processing.keys()) {
this.processing.set(baseId, false)
}
}
private async processItem(baseId: string, item: KnowledgeItem): Promise<void> {
try {
if (item.retryCount && item.retryCount >= this.MAX_RETRIES) {
console.log(`[KnowledgeQueue] Item ${item.id} has reached max retries, skipping`)
return
}
console.log(`[KnowledgeQueue] Starting to process item ${item.id} (${item.type})`)
store.dispatch(
updateItemProcessingStatus({
baseId,
itemId: item.id,
status: 'processing',
retryCount: (item.retryCount || 0) + 1
})
)
const base = store.getState().knowledge.bases.find((b) => b.id === baseId)
if (!base) {
throw new Error(`[KnowledgeQueue] Knowledge base ${baseId} not found`)
}
const baseParams = getKnowledgeBaseParams(base)
const sourceItem = base.items.find((i) => i.id === item.id)
if (!sourceItem) {
throw new Error(`[KnowledgeQueue] Source item ${item.id} not found in base ${baseId}`)
}
let result: AddLoaderReturn | null = null
let note, content
console.log(`[KnowledgeQueue] Processing item: ${sourceItem.content}`)
switch (item.type) {
case 'note':
note = await db.knowledge_notes.get(item.id)
if (note) {
content = note.content as string
result = await window.api.knowledgeBase.add({ base: baseParams, item: { ...sourceItem, content } })
}
break
default:
result = await window.api.knowledgeBase.add({ base: baseParams, item: sourceItem })
break
}
console.log(`[KnowledgeQueue] Successfully completed processing item ${item.id}`)
store.dispatch(
updateItemProcessingStatus({
baseId,
itemId: item.id,
status: 'completed'
})
)
if (result) {
store.dispatch(
updateBaseItemUniqueId({
baseId,
itemId: item.id,
uniqueId: result.uniqueId
})
)
}
console.debug(`[KnowledgeQueue] Updated uniqueId for item ${item.id} in base ${baseId}`)
setTimeout(() => store.dispatch(clearCompletedProcessing({ baseId })), 1000)
} catch (error) {
console.error(`[KnowledgeQueue] Error processing item ${item.id}:`, error)
store.dispatch(
updateItemProcessingStatus({
baseId,
itemId: item.id,
status: 'failed',
error: error instanceof Error ? error.message : 'Unknown error',
retryCount: (item.retryCount || 0) + 1
})
)
}
}
}
export default new KnowledgeQueue()

View File

@ -185,7 +185,6 @@ export async function fetchSuggestions({
}
export async function checkApi(provider: Provider) {
const model = provider.models[0]
const key = 'api-check'
const style = { marginTop: '3vh' }
@ -201,7 +200,7 @@ export async function checkApi(provider: Provider) {
return false
}
if (!model) {
if (isEmpty(provider.models)) {
window.message.error({ content: i18n.t('message.error.enter.model'), key, style })
return false
}

View File

@ -27,6 +27,8 @@ class FileManager {
}
static async uploadFile(file: FileType): Promise<FileType> {
console.debug(`[FileManager] Uploading file: ${JSON.stringify(file)}`)
const uploadFile = await window.api.file.upload(file)
const fileRecord = await db.files.get(uploadFile.id)

View File

@ -0,0 +1,21 @@
import AiProvider from '@renderer/providers/AiProvider'
import { KnowledgeBase, KnowledgeBaseParams } from '@renderer/types'
import { isEmpty } from 'lodash'
import { getProviderByModel } from './AssistantService'
export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams => {
const provider = getProviderByModel(base.model)
const aiProvider = new AiProvider(provider)
if (provider.id === 'ollama' && isEmpty(provider.apiKey)) {
provider.apiKey = 'empty'
}
return {
id: base.id,
model: base.model.name,
apiKey: aiProvider.getApiKey(),
baseURL: provider.apiHost + '/v1'
}
}

View File

@ -1,7 +1,7 @@
import { Assistant, FileType, FileTypes, Message } from '@renderer/types'
import { GPTTokens } from 'gpt-tokens'
import { flatten, takeRight } from 'lodash'
import { CompletionUsage } from 'openai/resources'
import { approximateTokenSize } from 'tokenx'
import { getAssistantSettings } from './AssistantService'
import { filterContextMessages, filterMessages } from './MessagesService'
@ -45,12 +45,7 @@ async function getMessageParam(message: Message): Promise<MessageItem[]> {
}
export function estimateTextTokens(text: string) {
const { usedTokens } = new GPTTokens({
model: 'gpt-4o',
messages: [{ role: 'user', content: text }]
})
return usedTokens - 7
return approximateTokenSize(text)
}
export function estimateImageTokens(file: FileType) {
@ -58,11 +53,6 @@ export function estimateImageTokens(file: FileType) {
}
export async function estimateMessageUsage(message: Message): Promise<CompletionUsage> {
const { usedTokens, promptUsedTokens, completionUsedTokens } = new GPTTokens({
model: 'gpt-4o',
messages: await getMessageParam(message)
})
let imageTokens = 0
if (message.files) {
@ -74,10 +64,12 @@ export async function estimateMessageUsage(message: Message): Promise<Completion
}
}
const tokens = estimateTextTokens(message.content)
return {
prompt_tokens: promptUsedTokens,
completion_tokens: completionUsedTokens,
total_tokens: usedTokens + (imageTokens ? imageTokens - 7 : 0)
prompt_tokens: tokens,
completion_tokens: tokens,
total_tokens: tokens + (imageTokens ? imageTokens - 7 : 0)
}
}
@ -121,16 +113,10 @@ export async function estimateHistoryTokens(assistant: Assistant, msgs: Message[
allMessages = allMessages.concat(items)
}
const { usedTokens } = new GPTTokens({
model: 'gpt-4o',
messages: [
{
role: 'system',
content: assistant.prompt
},
...flatten(allMessages)
]
})
const prompt = assistant.prompt
const input = flatten(allMessages)
.map((m) => m.content)
.join('\n')
return usedTokens - 7 + uasageTokens
return estimateTextTokens(prompt + input) + uasageTokens
}

View File

@ -5,6 +5,7 @@ import storage from 'redux-persist/lib/storage'
import agents from './agents'
import assistants from './assistants'
import knowledge from './knowledge'
import llm from './llm'
import migrate from './migrate'
import paintings from './paintings'
@ -19,7 +20,8 @@ const rootReducer = combineReducers({
llm,
settings,
runtime,
shortcuts
shortcuts,
knowledge
})
const persistedReducer = persistReducer(

View File

@ -0,0 +1,192 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import FileManager from '@renderer/services/FileManager'
import { FileType, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types'
export interface KnowledgeState {
bases: KnowledgeBase[]
}
const initialState: KnowledgeState = {
bases: []
}
const knowledgeSlice = createSlice({
name: 'knowledge',
initialState,
reducers: {
addBase(state, action: PayloadAction<KnowledgeBase>) {
state.bases.push(action.payload)
},
deleteBase(state, action: PayloadAction<{ baseId: string }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) {
state.bases = state.bases.filter((b) => b.id !== action.payload.baseId)
const files = base.items.filter((item) => item.type === 'file')
FileManager.deleteFiles(files.map((item) => item.content) as FileType[])
window.api.knowledgeBase.delete(action.payload.baseId)
}
},
renameBase(state, action: PayloadAction<{ baseId: string; name: string }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) {
base.name = action.payload.name
base.updated_at = Date.now()
}
},
updateBase(state, action: PayloadAction<KnowledgeBase>) {
const index = state.bases.findIndex((b) => b.id === action.payload.id)
if (index !== -1) {
state.bases[index] = action.payload
}
},
updateBases(state, action: PayloadAction<KnowledgeBase[]>) {
state.bases = action.payload
},
addItem(state, action: PayloadAction<{ baseId: string; item: KnowledgeItem }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) {
if (action.payload.item.type === 'file') {
action.payload.item.created_at = new Date(action.payload.item.created_at).getTime()
action.payload.item.updated_at = new Date(action.payload.item.updated_at).getTime()
base.items.push(action.payload.item)
}
if (action.payload.item.type === 'directory') {
const directoryExists = base.items.some((item) => item.content === action.payload.item.content)
if (!directoryExists) {
base.items.push(action.payload.item)
}
}
if (action.payload.item.type === 'url') {
const urlExists = base.items.some((item) => item.content === action.payload.item.content)
if (!urlExists) {
base.items.push(action.payload.item)
}
}
if (action.payload.item.type === 'sitemap') {
const sitemapExists = base.items.some((item) => item.content === action.payload.item.content)
if (!sitemapExists) {
base.items.push(action.payload.item)
}
}
if (action.payload.item.type === 'note') {
base.items.push(action.payload.item)
}
base.updated_at = Date.now()
}
},
removeItem(state, action: PayloadAction<{ baseId: string; item: KnowledgeItem }>) {
const { baseId } = action.payload
const base = state.bases.find((b) => b.id === baseId)
if (base) {
base.items = base.items.filter((item) => item.id !== action.payload.item.id)
base.updated_at = Date.now()
}
},
addFiles(state, action: PayloadAction<{ baseId: string; items: KnowledgeItem[] }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) {
base.items = [...base.items, ...action.payload.items]
base.updated_at = Date.now()
}
},
updateNotes(state, action: PayloadAction<{ baseId: string; item: KnowledgeItem }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) {
const existingNoteIndex = base.items.findIndex(
(item) => item.type === 'note' && item.id === action.payload.item.id
)
if (existingNoteIndex !== -1) {
base.items[existingNoteIndex] = action.payload.item
} else {
base.items.push(action.payload.item)
}
base.updated_at = Date.now()
}
},
updateItemProcessingStatus(
state,
action: PayloadAction<{
baseId: string
itemId: string
status: ProcessingStatus
progress?: number
error?: string
retryCount?: number
}>
) {
const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) {
const item = base.items.find((item) => item.id === action.payload.itemId)
if (item) {
item.processingStatus = action.payload.status
item.processingProgress = action.payload.progress
item.processingError = action.payload.error
item.retryCount = action.payload.retryCount
}
}
},
clearCompletedProcessing(state, action: PayloadAction<{ baseId: string }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) {
base.items.forEach((item) => {
if (item.processingStatus === 'completed' || item.processingStatus === 'failed') {
item.processingStatus = undefined
item.processingProgress = undefined
item.processingError = undefined
item.retryCount = undefined
}
})
}
},
clearAllProcessing(state, action: PayloadAction<{ baseId: string }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) {
base.items.forEach((item) => {
item.processingStatus = undefined
item.processingProgress = undefined
item.processingError = undefined
item.retryCount = undefined
})
}
},
updateBaseItemUniqueId(state, action: PayloadAction<{ baseId: string; itemId: string; uniqueId: string }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) {
const item = base.items.find((item) => item.id === action.payload.itemId)
if (item) {
item.uniqueId = action.payload.uniqueId
}
}
}
}
})
export const {
addBase,
deleteBase,
renameBase,
updateBase,
updateBases,
addItem,
addFiles,
updateNotes,
removeItem,
updateItemProcessingStatus,
clearCompletedProcessing,
clearAllProcessing,
updateBaseItemUniqueId
} = knowledgeSlice.actions
export default knowledgeSlice.reducer

View File

@ -48,6 +48,7 @@ export type Message = {
images?: string[]
usage?: OpenAI.Completions.CompletionUsage
metrics?: Metrics
knowledgeBaseIds?: string[]
type: 'text' | '@' | 'clear'
isPreset?: boolean
}
@ -88,7 +89,7 @@ export type Provider = {
export type ProviderType = 'openai' | 'anthropic' | 'gemini'
export type ModelType = 'text' | 'vision'
export type ModelType = 'text' | 'vision' | 'embedding'
export type Model = {
id: string
@ -179,3 +180,38 @@ export interface Shortcut {
enabled: boolean
system: boolean
}
export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed'
export type KnowledgeItemType = 'file' | 'url' | 'note' | 'sitemap' | 'directory'
export type KnowledgeItem = {
id: string
baseId?: string
uniqueId?: string
type: KnowledgeItemType
content: string | FileType
created_at: number
updated_at: number
processingStatus?: ProcessingStatus
processingProgress?: number
processingError?: string
retryCount?: number
}
export interface KnowledgeBase {
id: string
name: string
model: Model
description?: string
items: KnowledgeItem[]
created_at: number
updated_at: number
}
export type KnowledgeBaseParams = {
id: string
model: string
apiKey: string
baseURL: string
}

1561
yarn.lock

File diff suppressed because it is too large Load Diff