Compare commits
2 Commits
v1.6.0-bet
...
feat/claud
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd6d6bd56e | ||
|
|
7a23386de4 |
66
.github/workflows/auto-i18n.yml
vendored
66
.github/workflows/auto-i18n.yml
vendored
@@ -1,66 +0,0 @@
|
||||
name: Auto I18N
|
||||
|
||||
env:
|
||||
API_KEY: ${{ secrets.TRANSLATE_API_KEY}}
|
||||
MODEL: ${{ vars.MODEL || 'deepseek/deepseek-v3.1'}}
|
||||
BASE_URL: ${{ vars.BASE_URL || 'https://api.ppinfra.com/openai'}}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
auto-i18n:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.head.repo.full_name == 'CherryHQ/cherry-studio'
|
||||
name: Auto I18N
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: 🐈⬛ Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
|
||||
- name: 📦 Setting Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: 📦 Install dependencies in isolated directory
|
||||
run: |
|
||||
# 在临时目录安装依赖
|
||||
mkdir -p /tmp/translation-deps
|
||||
cd /tmp/translation-deps
|
||||
echo '{"dependencies": {"openai": "^5.12.2", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "prettier": "^3.5.3", "prettier-plugin-sort-json": "^4.1.1"}}' > package.json
|
||||
npm install --no-package-lock
|
||||
|
||||
# 设置 NODE_PATH 让项目能找到这些依赖
|
||||
echo "NODE_PATH=/tmp/translation-deps/node_modules" >> $GITHUB_ENV
|
||||
|
||||
- name: 🏃♀️ Translate
|
||||
run: npx tsx scripts/auto-translate-i18n.ts
|
||||
|
||||
- name: 🔍 Format
|
||||
run: cd /tmp/translation-deps && npx prettier --write --config /home/runner/work/cherry-studio/cherry-studio/.prettierrc /home/runner/work/cherry-studio/cherry-studio/src/renderer/src/i18n/
|
||||
|
||||
- name: 🔄 Commit changes
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add .
|
||||
git reset -- package.json yarn.lock # 不提交 package.json 和 yarn.lock 的更改
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "fix(i18n): Auto update translations for PR #${{ github.event.pull_request.number }}"
|
||||
fi
|
||||
|
||||
- name: 🚀 Push changes
|
||||
uses: ad-m/github-push-action@master
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: ${{ github.event.pull_request.head.ref }}
|
||||
54
.github/workflows/claude-code-review.yml
vendored
54
.github/workflows/claude-code-review.yml
vendored
@@ -1,54 +0,0 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
|
||||
|
||||
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
||||
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
|
||||
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
|
||||
60
.github/workflows/claude.yml
vendored
60
.github/workflows/claude.yml
vendored
@@ -1,60 +0,0 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment'
|
||||
&& contains(github.event.comment.body, '@claude')
|
||||
&& contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.comment.author_association))
|
||||
||
|
||||
(github.event_name == 'pull_request_review_comment'
|
||||
&& contains(github.event.comment.body, '@claude')
|
||||
&& contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.comment.author_association))
|
||||
||
|
||||
(github.event_name == 'pull_request_review'
|
||||
&& contains(github.event.review.body, '@claude')
|
||||
&& contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.review.author_association))
|
||||
||
|
||||
(github.event_name == 'issues'
|
||||
&& (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))
|
||||
&& contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.issue.author_association))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
|
||||
# claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'
|
||||
@@ -121,25 +121,12 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
✨ 新功能:
|
||||
- 重构知识库模块,提升文档处理能力和搜索性能
|
||||
- 新增 PaddleOCR 支持,增强文档识别能力
|
||||
- 支持自定义窗口控制按钮样式
|
||||
- 新增 AI SDK 包,扩展 AI 能力集成
|
||||
- 支持标签页拖拽重排序功能
|
||||
- 增强笔记编辑器的同步和日志功能
|
||||
|
||||
🔧 性能优化:
|
||||
- 优化 MCP 服务的日志记录和错误处理
|
||||
- 改进 WebView 服务的 User-Agent 处理
|
||||
- 优化迷你应用的标题栏样式和状态栏适配
|
||||
- 重构依赖管理,清理和优化 package.json
|
||||
- 优化AI服务连接方式,提升响应速度和稳定性
|
||||
- 改进模型列表获取功能,减少不必要的网络请求
|
||||
- 增强各AI服务商的兼容性和连接可靠性
|
||||
|
||||
🐛 问题修复:
|
||||
- 修复输入栏无限状态更新循环问题
|
||||
- 修复窗口控制提示框的鼠标悬停延迟
|
||||
- 修复翻译输入框粘贴多内容源的处理
|
||||
- 修复导航服务初始化时序问题
|
||||
- 修复 MCP 通过 JSON 添加时的参数转换
|
||||
- 修复模型作用域服务器同步时的 URL 格式
|
||||
- 标准化工具提示图标样式
|
||||
- 修复部分AI服务商连接失败的问题
|
||||
- 修复模型配置加载时的潜在错误
|
||||
- 提升应用整体稳定性和容错能力
|
||||
|
||||
@@ -23,7 +23,10 @@ export default defineConfig({
|
||||
'@shared': resolve('packages/shared'),
|
||||
'@logger': resolve('src/main/services/LoggerService'),
|
||||
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'),
|
||||
'@mcp-trace/trace-node': resolve('packages/mcp-trace/trace-node')
|
||||
'@mcp-trace/trace-node': resolve('packages/mcp-trace/trace-node'),
|
||||
'@cherrystudio/ai-core/provider': resolve('packages/aiCore/src/core/providers'),
|
||||
'@cherrystudio/ai-core/built-in/plugins': resolve('packages/aiCore/src/core/plugins/built-in'),
|
||||
'@cherrystudio/ai-core': resolve('packages/aiCore/src')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
|
||||
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.6.0-beta.7",
|
||||
"version": "1.6.0-beta.6",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -74,7 +74,8 @@
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"faiss-node": "^0.5.1",
|
||||
"ai-sdk-provider-claude-code": "^1.1.3",
|
||||
"express": "^5.1.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"jsdom": "26.1.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
@@ -128,10 +129,8 @@
|
||||
"@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@langchain/community": "^0.3.50",
|
||||
"@langchain/core": "^0.3.68",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@langchain/ollama": "^0.2.1",
|
||||
"@langchain/openai": "^0.6.7",
|
||||
"@mistralai/mistralai": "^1.7.5",
|
||||
"@modelcontextprotocol/sdk": "^1.17.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
@@ -172,13 +171,12 @@
|
||||
"@truto/turndown-plugin-gfm": "^1.0.2",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
"@types/cli-progress": "^3",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/fs-extra": "^11",
|
||||
"@types/he": "^1",
|
||||
"@types/html-to-text": "^9",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/markdown-it": "^14",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/mime-types": "^3",
|
||||
"@types/node": "^22.17.1",
|
||||
"@types/pako": "^1.0.2",
|
||||
"@types/react": "^19.0.12",
|
||||
@@ -206,7 +204,6 @@
|
||||
"axios": "^1.7.3",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"chardet": "^2.1.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"chokidar": "^4.0.3",
|
||||
"cli-progress": "^3.12.0",
|
||||
"code-inspector-plugin": "^0.20.14",
|
||||
@@ -243,7 +240,6 @@
|
||||
"he": "^1.2.0",
|
||||
"html-tags": "^5.1.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"html-to-text": "^9.0.5",
|
||||
"htmlparser2": "^10.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^23.11.5",
|
||||
@@ -260,14 +256,12 @@
|
||||
"markdown-it": "^14.1.0",
|
||||
"mermaid": "^11.10.1",
|
||||
"mime": "^4.0.4",
|
||||
"mime-types": "^3.0.1",
|
||||
"motion": "^12.10.5",
|
||||
"notion-helper": "^1.3.22",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
|
||||
"p-queue": "^8.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"playwright": "^1.52.0",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
@@ -280,7 +274,6 @@
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-json-view": "^1.21.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-player": "^3.3.1",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router": "6",
|
||||
"react-router-dom": "6",
|
||||
@@ -323,7 +316,6 @@
|
||||
"word-extractor": "^1.0.4",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.27",
|
||||
"youtubei.js": "^15.0.1",
|
||||
"zipread": "^1.3.3",
|
||||
"zod": "^3.25.74"
|
||||
},
|
||||
@@ -346,7 +338,13 @@
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"undici": "6.21.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch"
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||
"@img/sharp-darwin-arm64": "0.34.3",
|
||||
"@img/sharp-darwin-x64": "0.34.3",
|
||||
"@img/sharp-linux-arm": "0.34.3",
|
||||
"@img/sharp-linux-arm64": "0.34.3",
|
||||
"@img/sharp-linux-x64": "0.34.3",
|
||||
"@img/sharp-win32-x64": "0.34.3"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -123,12 +123,6 @@ export enum IpcChannel {
|
||||
Windows_SetMinimumSize = 'window:set-minimum-size',
|
||||
Windows_Resize = 'window:resize',
|
||||
Windows_GetSize = 'window:get-size',
|
||||
Windows_Minimize = 'window:minimize',
|
||||
Windows_Maximize = 'window:maximize',
|
||||
Windows_Unmaximize = 'window:unmaximize',
|
||||
Windows_Close = 'window:close',
|
||||
Windows_IsMaximized = 'window:is-maximized',
|
||||
Windows_MaximizedChanged = 'window:maximized-changed',
|
||||
|
||||
KnowledgeBase_Create = 'knowledge-base:create',
|
||||
KnowledgeBase_Reset = 'knowledge-base:reset',
|
||||
@@ -256,6 +250,7 @@ export enum IpcChannel {
|
||||
|
||||
// Provider
|
||||
Provider_AddKey = 'provider:add-key',
|
||||
Provider_GetClaudeCodePort = 'provider:get-claude-code-port',
|
||||
|
||||
//Selection Assistant
|
||||
Selection_TextSelected = 'selection:text-selected',
|
||||
|
||||
@@ -7,7 +7,7 @@ export type LoaderReturn = {
|
||||
loaderType: string
|
||||
status?: ProcessingStatus
|
||||
message?: string
|
||||
messageSource?: 'preprocess' | 'embedding' | 'validation'
|
||||
messageSource?: 'preprocess' | 'embedding'
|
||||
}
|
||||
|
||||
export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
|
||||
|
||||
@@ -13,6 +13,7 @@ import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electro
|
||||
|
||||
import { isDev, isLinux, isWin } from './constant'
|
||||
import { registerIpc } from './ipc'
|
||||
import { claudeCodeService } from './services/ClaudeCodeService'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import mcpService from './services/MCPService'
|
||||
import { nodeTraceService } from './services/NodeTraceService'
|
||||
@@ -119,6 +120,14 @@ if (!app.requestSingleInstanceLock()) {
|
||||
|
||||
nodeTraceService.init()
|
||||
|
||||
// Start Claude-code HTTP service
|
||||
try {
|
||||
await claudeCodeService.start()
|
||||
logger.info('Claude-code HTTP service started successfully')
|
||||
} catch (error) {
|
||||
logger.error('Failed to start Claude-code HTTP service:', error as Error)
|
||||
}
|
||||
|
||||
app.on('activate', function () {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
@@ -193,6 +202,15 @@ if (!app.requestSingleInstanceLock()) {
|
||||
} catch (error) {
|
||||
logger.warn('Error cleaning up MCP service:', error as Error)
|
||||
}
|
||||
|
||||
// Stop Claude-code HTTP service
|
||||
try {
|
||||
await claudeCodeService.stop()
|
||||
logger.info('Claude-code HTTP service stopped')
|
||||
} catch (error) {
|
||||
logger.warn('Error stopping Claude-code HTTP service:', error as Error)
|
||||
}
|
||||
|
||||
// finish the logger
|
||||
logger.finish()
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
|
||||
import { claudeCodeService } from './services/ClaudeCodeService'
|
||||
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
@@ -24,7 +25,7 @@ import DxtService from './services/DxtService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
import { fileStorage as fileManager } from './services/FileStorage'
|
||||
import FileService from './services/FileSystemService'
|
||||
import KnowledgeService from './services/knowledge/KnowledgeService'
|
||||
import KnowledgeService from './services/KnowledgeService'
|
||||
import mcpService from './services/MCPService'
|
||||
import MemoryService from './services/memory/MemoryService'
|
||||
import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceService'
|
||||
@@ -524,6 +525,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
}
|
||||
})
|
||||
|
||||
// knowledge base
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create.bind(KnowledgeService))
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Reset, KnowledgeService.reset.bind(KnowledgeService))
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Delete, KnowledgeService.delete.bind(KnowledgeService))
|
||||
@@ -587,41 +589,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
return [width, height]
|
||||
})
|
||||
|
||||
// Window Controls
|
||||
ipcMain.handle(IpcChannel.Windows_Minimize, () => {
|
||||
checkMainWindow()
|
||||
mainWindow.minimize()
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Windows_Maximize, () => {
|
||||
checkMainWindow()
|
||||
mainWindow.maximize()
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Windows_Unmaximize, () => {
|
||||
checkMainWindow()
|
||||
mainWindow.unmaximize()
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Windows_Close, () => {
|
||||
checkMainWindow()
|
||||
mainWindow.close()
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Windows_IsMaximized, () => {
|
||||
checkMainWindow()
|
||||
return mainWindow.isMaximized()
|
||||
})
|
||||
|
||||
// Send maximized state changes to renderer
|
||||
mainWindow.on('maximize', () => {
|
||||
mainWindow.webContents.send(IpcChannel.Windows_MaximizedChanged, true)
|
||||
})
|
||||
|
||||
mainWindow.on('unmaximize', () => {
|
||||
mainWindow.webContents.send(IpcChannel.Windows_MaximizedChanged, false)
|
||||
})
|
||||
|
||||
// VertexAI
|
||||
ipcMain.handle(IpcChannel.VertexAI_GetAuthHeaders, async (_, params) => {
|
||||
return vertexAIService.getAuthHeaders(params)
|
||||
@@ -789,4 +756,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// CherryIN
|
||||
ipcMain.handle(IpcChannel.Cherryin_GetSignature, (_, params) => generateSignature(params))
|
||||
|
||||
// Provider
|
||||
ipcMain.handle(IpcChannel.Provider_GetClaudeCodePort, () => {
|
||||
return claudeCodeService.getPort()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
|
||||
import type { Embeddings } from '@langchain/core/embeddings'
|
||||
import { OllamaEmbeddings } from '@langchain/ollama'
|
||||
import { AzureOpenAIEmbeddings, OpenAIEmbeddings } from '@langchain/openai'
|
||||
import { ApiClient, SystemProviderIds } from '@types'
|
||||
|
||||
import { isJinaEmbeddingsModel, JinaEmbeddings } from './JinaEmbeddings'
|
||||
|
||||
export default class EmbeddingsFactory {
|
||||
static create({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }): Embeddings {
|
||||
const batchSize = 10
|
||||
const { model, provider, apiKey, apiVersion, baseURL } = embedApiClient
|
||||
if (provider === SystemProviderIds.ollama) {
|
||||
let baseUrl = baseURL
|
||||
if (baseURL.includes('v1/')) {
|
||||
baseUrl = baseURL.replace('v1/', '')
|
||||
}
|
||||
const headers = apiKey
|
||||
? {
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
}
|
||||
: undefined
|
||||
return new OllamaEmbeddings({
|
||||
model: model,
|
||||
baseUrl,
|
||||
...headers
|
||||
})
|
||||
} else if (provider === SystemProviderIds.voyageai) {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
outputDimension: dimensions,
|
||||
batchSize
|
||||
})
|
||||
}
|
||||
if (isJinaEmbeddingsModel(model)) {
|
||||
return new JinaEmbeddings({
|
||||
model,
|
||||
apiKey,
|
||||
batchSize,
|
||||
dimensions,
|
||||
baseUrl: baseURL
|
||||
})
|
||||
}
|
||||
if (apiVersion !== undefined) {
|
||||
return new AzureOpenAIEmbeddings({
|
||||
azureOpenAIApiKey: apiKey,
|
||||
azureOpenAIApiVersion: apiVersion,
|
||||
azureOpenAIApiDeploymentName: model,
|
||||
azureOpenAIEndpoint: baseURL,
|
||||
dimensions,
|
||||
batchSize
|
||||
})
|
||||
}
|
||||
return new OpenAIEmbeddings({
|
||||
model,
|
||||
apiKey,
|
||||
dimensions,
|
||||
batchSize,
|
||||
configuration: { baseURL }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { Embeddings, type EmbeddingsParams } from '@langchain/core/embeddings'
|
||||
import { chunkArray } from '@langchain/core/utils/chunk_array'
|
||||
import { getEnvironmentVariable } from '@langchain/core/utils/env'
|
||||
import z from 'zod/v4'
|
||||
|
||||
const jinaModelSchema = z.union([
|
||||
z.literal('jina-clip-v2'),
|
||||
z.literal('jina-embeddings-v3'),
|
||||
z.literal('jina-colbert-v2'),
|
||||
z.literal('jina-clip-v1'),
|
||||
z.literal('jina-colbert-v1-en'),
|
||||
z.literal('jina-embeddings-v2-base-es'),
|
||||
z.literal('jina-embeddings-v2-base-code'),
|
||||
z.literal('jina-embeddings-v2-base-de'),
|
||||
z.literal('jina-embeddings-v2-base-zh'),
|
||||
z.literal('jina-embeddings-v2-base-en')
|
||||
])
|
||||
|
||||
type JinaModel = z.infer<typeof jinaModelSchema>
|
||||
|
||||
export const isJinaEmbeddingsModel = (model: string): model is JinaModel => {
|
||||
return jinaModelSchema.safeParse(model).success
|
||||
}
|
||||
|
||||
interface JinaEmbeddingsParams extends EmbeddingsParams {
|
||||
/** Model name to use */
|
||||
model: JinaModel
|
||||
|
||||
baseUrl?: string
|
||||
|
||||
/**
|
||||
* Timeout to use when making requests to Jina.
|
||||
*/
|
||||
timeout?: number
|
||||
|
||||
/**
|
||||
* The maximum number of documents to embed in a single request.
|
||||
*/
|
||||
batchSize?: number
|
||||
|
||||
/**
|
||||
* Whether to strip new lines from the input text.
|
||||
*/
|
||||
stripNewLines?: boolean
|
||||
|
||||
/**
|
||||
* The dimensions of the embedding.
|
||||
*/
|
||||
dimensions?: number
|
||||
|
||||
/**
|
||||
* Scales the embedding so its Euclidean (L2) norm becomes 1, preserving direction. Useful when downstream involves dot-product, classification, visualization..
|
||||
*/
|
||||
normalized?: boolean
|
||||
}
|
||||
|
||||
type JinaMultiModelInput =
|
||||
| {
|
||||
text: string
|
||||
image?: never
|
||||
}
|
||||
| {
|
||||
image: string
|
||||
text?: never
|
||||
}
|
||||
|
||||
type JinaEmbeddingsInput = string | JinaMultiModelInput
|
||||
|
||||
interface EmbeddingCreateParams {
|
||||
model: JinaEmbeddingsParams['model']
|
||||
|
||||
/**
|
||||
* input can be strings or JinaMultiModelInputs,if you want embed image,you should use JinaMultiModelInputs
|
||||
*/
|
||||
input: JinaEmbeddingsInput[]
|
||||
dimensions: number
|
||||
task?: 'retrieval.query' | 'retrieval.passage'
|
||||
}
|
||||
|
||||
interface EmbeddingResponse {
|
||||
model: string
|
||||
object: string
|
||||
usage: {
|
||||
total_tokens: number
|
||||
prompt_tokens: number
|
||||
}
|
||||
data: {
|
||||
object: string
|
||||
index: number
|
||||
embedding: number[]
|
||||
}[]
|
||||
}
|
||||
|
||||
interface EmbeddingErrorResponse {
|
||||
detail: string
|
||||
}
|
||||
|
||||
export class JinaEmbeddings extends Embeddings implements JinaEmbeddingsParams {
|
||||
model: JinaEmbeddingsParams['model'] = 'jina-clip-v2'
|
||||
|
||||
batchSize = 24
|
||||
|
||||
baseUrl = 'https://api.jina.ai/v1/embeddings'
|
||||
|
||||
stripNewLines = true
|
||||
|
||||
dimensions = 1024
|
||||
|
||||
apiKey: string
|
||||
|
||||
constructor(
|
||||
fields?: Partial<JinaEmbeddingsParams> & {
|
||||
apiKey?: string
|
||||
}
|
||||
) {
|
||||
const fieldsWithDefaults = { maxConcurrency: 2, ...fields }
|
||||
super(fieldsWithDefaults)
|
||||
|
||||
const apiKey =
|
||||
fieldsWithDefaults?.apiKey || getEnvironmentVariable('JINA_API_KEY') || getEnvironmentVariable('JINA_AUTH_TOKEN')
|
||||
|
||||
if (!apiKey) throw new Error('Jina API key not found')
|
||||
|
||||
this.apiKey = apiKey
|
||||
this.baseUrl = fieldsWithDefaults?.baseUrl ? `${fieldsWithDefaults?.baseUrl}embeddings` : this.baseUrl
|
||||
this.model = fieldsWithDefaults?.model ?? this.model
|
||||
this.dimensions = fieldsWithDefaults?.dimensions ?? this.dimensions
|
||||
this.batchSize = fieldsWithDefaults?.batchSize ?? this.batchSize
|
||||
this.stripNewLines = fieldsWithDefaults?.stripNewLines ?? this.stripNewLines
|
||||
}
|
||||
|
||||
private doStripNewLines(input: JinaEmbeddingsInput[]) {
|
||||
if (this.stripNewLines) {
|
||||
return input.map((i) => {
|
||||
if (typeof i === 'string') {
|
||||
return i.replace(/\n/g, ' ')
|
||||
}
|
||||
if (i.text) {
|
||||
return { text: i.text.replace(/\n/g, ' ') }
|
||||
}
|
||||
return i
|
||||
})
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
async embedDocuments(input: JinaEmbeddingsInput[]): Promise<number[][]> {
|
||||
const batches = chunkArray(this.doStripNewLines(input), this.batchSize)
|
||||
const batchRequests = batches.map((batch) => {
|
||||
const params = this.getParams(batch)
|
||||
return this.embeddingWithRetry(params)
|
||||
})
|
||||
|
||||
const batchResponses = await Promise.all(batchRequests)
|
||||
const embeddings: number[][] = []
|
||||
|
||||
for (let i = 0; i < batchResponses.length; i += 1) {
|
||||
const batch = batches[i]
|
||||
const batchResponse = batchResponses[i] || []
|
||||
for (let j = 0; j < batch.length; j += 1) {
|
||||
embeddings.push(batchResponse[j])
|
||||
}
|
||||
}
|
||||
|
||||
return embeddings
|
||||
}
|
||||
|
||||
async embedQuery(input: JinaEmbeddingsInput): Promise<number[]> {
|
||||
const params = this.getParams(this.doStripNewLines([input]), true)
|
||||
|
||||
const embeddings = (await this.embeddingWithRetry(params)) || [[]]
|
||||
return embeddings[0]
|
||||
}
|
||||
|
||||
private getParams(input: JinaEmbeddingsInput[], query?: boolean): EmbeddingCreateParams {
|
||||
return {
|
||||
model: this.model,
|
||||
input,
|
||||
dimensions: this.dimensions,
|
||||
task: query ? 'retrieval.query' : this.model === 'jina-clip-v2' ? undefined : 'retrieval.passage'
|
||||
}
|
||||
}
|
||||
|
||||
private async embeddingWithRetry(body: EmbeddingCreateParams) {
|
||||
const response = await fetch(this.baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
const embeddingData: EmbeddingResponse | EmbeddingErrorResponse = await response.json()
|
||||
if ('detail' in embeddingData && embeddingData.detail) {
|
||||
throw new Error(`${embeddingData.detail}`)
|
||||
}
|
||||
return (embeddingData as EmbeddingResponse).data.map(({ embedding }) => embedding)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Embeddings as BaseEmbeddings } from '@langchain/core/embeddings'
|
||||
import { TraceMethod } from '@mcp-trace/trace-core'
|
||||
import { ApiClient } from '@types'
|
||||
|
||||
import EmbeddingsFactory from './EmbeddingsFactory'
|
||||
|
||||
export default class TextEmbeddings {
|
||||
private sdk: BaseEmbeddings
|
||||
constructor({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }) {
|
||||
this.sdk = EmbeddingsFactory.create({
|
||||
embedApiClient,
|
||||
dimensions
|
||||
})
|
||||
}
|
||||
|
||||
@TraceMethod({ spanName: 'embedDocuments', tag: 'Embeddings' })
|
||||
public async embedDocuments(texts: string[]): Promise<number[][]> {
|
||||
return this.sdk.embedDocuments(texts)
|
||||
}
|
||||
|
||||
@TraceMethod({ spanName: 'embedQuery', tag: 'Embeddings' })
|
||||
public async embedQuery(text: string): Promise<number[]> {
|
||||
return this.sdk.embedQuery(text)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { BaseDocumentLoader } from '@langchain/core/document_loaders/base'
|
||||
import { Document } from '@langchain/core/documents'
|
||||
import { readTextFileWithAutoEncoding } from '@main/utils/file'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
export class MarkdownLoader extends BaseDocumentLoader {
|
||||
private path: string
|
||||
private md: MarkdownIt
|
||||
|
||||
constructor(path: string) {
|
||||
super()
|
||||
this.path = path
|
||||
this.md = new MarkdownIt()
|
||||
}
|
||||
public async load(): Promise<Document[]> {
|
||||
const content = await readTextFileWithAutoEncoding(this.path)
|
||||
return this.parseMarkdown(content)
|
||||
}
|
||||
|
||||
private parseMarkdown(content: string): Document[] {
|
||||
const tokens = this.md.parse(content, {})
|
||||
const documents: Document[] = []
|
||||
|
||||
let currentSection: {
|
||||
heading?: string
|
||||
level?: number
|
||||
content: string
|
||||
startLine?: number
|
||||
} = { content: '' }
|
||||
|
||||
let i = 0
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i]
|
||||
|
||||
if (token.type === 'heading_open') {
|
||||
// Save previous section if it has content
|
||||
if (currentSection.content.trim()) {
|
||||
documents.push(
|
||||
new Document({
|
||||
pageContent: currentSection.content.trim(),
|
||||
metadata: {
|
||||
source: this.path,
|
||||
heading: currentSection.heading || 'Introduction',
|
||||
level: currentSection.level || 0,
|
||||
startLine: currentSection.startLine || 0
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Start new section
|
||||
const level = parseInt(token.tag.slice(1)) // Extract number from h1, h2, etc.
|
||||
const headingContent = tokens[i + 1]?.content || ''
|
||||
|
||||
currentSection = {
|
||||
heading: headingContent,
|
||||
level: level,
|
||||
content: '',
|
||||
startLine: token.map?.[0] || 0
|
||||
}
|
||||
|
||||
// Skip heading_open, inline, heading_close tokens
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
|
||||
// Add token content to current section
|
||||
if (token.content) {
|
||||
currentSection.content += token.content
|
||||
}
|
||||
|
||||
// Add newlines for block tokens
|
||||
if (token.block && token.type !== 'heading_close') {
|
||||
currentSection.content += '\n'
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
// Add the last section
|
||||
if (currentSection.content.trim()) {
|
||||
documents.push(
|
||||
new Document({
|
||||
pageContent: currentSection.content.trim(),
|
||||
metadata: {
|
||||
source: this.path,
|
||||
heading: currentSection.heading || 'Introduction',
|
||||
level: currentSection.level || 0,
|
||||
startLine: currentSection.startLine || 0
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return documents
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { BaseDocumentLoader } from '@langchain/core/document_loaders/base'
|
||||
import { Document } from '@langchain/core/documents'
|
||||
|
||||
export class NoteLoader extends BaseDocumentLoader {
|
||||
private text: string
|
||||
private sourceUrl?: string
|
||||
constructor(
|
||||
public _text: string,
|
||||
public _sourceUrl?: string
|
||||
) {
|
||||
super()
|
||||
this.text = _text
|
||||
this.sourceUrl = _sourceUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* A protected method that takes a `raw` string as a parameter and returns
|
||||
* a promise that resolves to an array containing the raw text as a single
|
||||
* element.
|
||||
* @param raw The raw text to be parsed.
|
||||
* @returns A promise that resolves to an array containing the raw text as a single element.
|
||||
*/
|
||||
protected async parse(raw: string): Promise<string[]> {
|
||||
return [raw]
|
||||
}
|
||||
|
||||
public async load(): Promise<Document[]> {
|
||||
const metadata = { source: this.sourceUrl || 'note' }
|
||||
const parsed = await this.parse(this.text)
|
||||
parsed.forEach((pageContent, i) => {
|
||||
if (typeof pageContent !== 'string') {
|
||||
throw new Error(`Expected string, at position ${i} got ${typeof pageContent}`)
|
||||
}
|
||||
})
|
||||
|
||||
return parsed.map(
|
||||
(pageContent, i) =>
|
||||
new Document({
|
||||
pageContent,
|
||||
metadata:
|
||||
parsed.length === 1
|
||||
? metadata
|
||||
: {
|
||||
...metadata,
|
||||
line: i + 1
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import { BaseDocumentLoader } from '@langchain/core/document_loaders/base'
|
||||
import { Document } from '@langchain/core/documents'
|
||||
import { Innertube } from 'youtubei.js'
|
||||
|
||||
// ... (接口定义 YoutubeConfig 和 VideoMetadata 保持不变)
|
||||
|
||||
/**
|
||||
* Configuration options for the YoutubeLoader class. Includes properties
|
||||
* such as the videoId, language, and addVideoInfo.
|
||||
*/
|
||||
interface YoutubeConfig {
|
||||
videoId: string
|
||||
language?: string
|
||||
addVideoInfo?: boolean
|
||||
// 新增一个选项,用于控制输出格式
|
||||
transcriptFormat?: 'text' | 'srt'
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata of a YouTube video. Includes properties such as the source
|
||||
* (videoId), description, title, view_count, author, and category.
|
||||
*/
|
||||
interface VideoMetadata {
|
||||
source: string
|
||||
description?: string
|
||||
title?: string
|
||||
view_count?: number
|
||||
author?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A document loader for loading data from YouTube videos. It uses the
|
||||
* youtubei.js library to fetch the transcript and video metadata.
|
||||
* @example
|
||||
* ```typescript
|
||||
* const loader = new YoutubeLoader({
|
||||
* videoId: "VIDEO_ID",
|
||||
* language: "en",
|
||||
* addVideoInfo: true,
|
||||
* transcriptFormat: "srt" // 获取 SRT 格式
|
||||
* });
|
||||
* const docs = await loader.load();
|
||||
* console.log(docs[0].pageContent);
|
||||
* ```
|
||||
*/
|
||||
export class YoutubeLoader extends BaseDocumentLoader {
|
||||
private videoId: string
|
||||
private language?: string
|
||||
private addVideoInfo: boolean
|
||||
// 新增格式化选项的私有属性
|
||||
private transcriptFormat: 'text' | 'srt'
|
||||
|
||||
constructor(config: YoutubeConfig) {
|
||||
super()
|
||||
this.videoId = config.videoId
|
||||
this.language = config?.language
|
||||
this.addVideoInfo = config?.addVideoInfo ?? false
|
||||
// 初始化格式化选项,默认为 'text' 以保持向后兼容
|
||||
this.transcriptFormat = config?.transcriptFormat ?? 'text'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the videoId from a YouTube video URL.
|
||||
* @param url The URL of the YouTube video.
|
||||
* @returns The videoId of the YouTube video.
|
||||
*/
|
||||
private static getVideoID(url: string): string {
|
||||
const match = url.match(/.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#&?]*).*/)
|
||||
if (match !== null && match[1].length === 11) {
|
||||
return match[1]
|
||||
} else {
|
||||
throw new Error('Failed to get youtube video id from the url')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance of the YoutubeLoader class from a YouTube video
|
||||
* URL.
|
||||
* @param url The URL of the YouTube video.
|
||||
* @param config Optional configuration options for the YoutubeLoader instance, excluding the videoId.
|
||||
* @returns A new instance of the YoutubeLoader class.
|
||||
*/
|
||||
static createFromUrl(url: string, config?: Omit<YoutubeConfig, 'videoId'>): YoutubeLoader {
|
||||
const videoId = YoutubeLoader.getVideoID(url)
|
||||
return new YoutubeLoader({ ...config, videoId })
|
||||
}
|
||||
|
||||
/**
|
||||
* [新增] 辅助函数:将毫秒转换为 SRT 时间戳格式 (HH:MM:SS,ms)
|
||||
* @param ms 毫秒数
|
||||
* @returns 格式化后的时间字符串
|
||||
*/
|
||||
private static formatTimestamp(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
.toString()
|
||||
.padStart(2, '0')
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
.toString()
|
||||
.padStart(2, '0')
|
||||
const seconds = (totalSeconds % 60).toString().padStart(2, '0')
|
||||
const milliseconds = (ms % 1000).toString().padStart(3, '0')
|
||||
return `${hours}:${minutes}:${seconds},${milliseconds}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the transcript and video metadata from the specified YouTube
|
||||
* video. It can return the transcript as plain text or in SRT format.
|
||||
* @returns An array of Documents representing the retrieved data.
|
||||
*/
|
||||
async load(): Promise<Document[]> {
|
||||
const metadata: VideoMetadata = {
|
||||
source: this.videoId
|
||||
}
|
||||
|
||||
try {
|
||||
const youtube = await Innertube.create({
|
||||
lang: this.language,
|
||||
retrieve_player: false
|
||||
})
|
||||
|
||||
const info = await youtube.getInfo(this.videoId)
|
||||
const transcriptData = await info.getTranscript()
|
||||
|
||||
if (!transcriptData.transcript.content?.body?.initial_segments) {
|
||||
throw new Error('Transcript segments not found in the response.')
|
||||
}
|
||||
|
||||
const segments = transcriptData.transcript.content.body.initial_segments
|
||||
|
||||
let pageContent: string
|
||||
|
||||
// 根据 transcriptFormat 选项决定如何格式化字幕
|
||||
if (this.transcriptFormat === 'srt') {
|
||||
// [修改] 将字幕片段格式化为 SRT 格式
|
||||
pageContent = segments
|
||||
.map((segment, index) => {
|
||||
const srtIndex = index + 1
|
||||
const startTime = YoutubeLoader.formatTimestamp(Number(segment.start_ms))
|
||||
const endTime = YoutubeLoader.formatTimestamp(Number(segment.end_ms))
|
||||
const text = segment.snippet?.text || '' // 使用 segment.snippet.text
|
||||
|
||||
return `${srtIndex}\n${startTime} --> ${endTime}\n${text}`
|
||||
})
|
||||
.join('\n\n') // 每个 SRT 块之间用两个换行符分隔
|
||||
} else {
|
||||
// [原始逻辑] 拼接为纯文本
|
||||
pageContent = segments.map((segment) => segment.snippet?.text || '').join(' ')
|
||||
}
|
||||
|
||||
if (this.addVideoInfo) {
|
||||
const basicInfo = info.basic_info
|
||||
metadata.description = basicInfo.short_description
|
||||
metadata.title = basicInfo.title
|
||||
metadata.view_count = basicInfo.view_count
|
||||
metadata.author = basicInfo.author
|
||||
}
|
||||
|
||||
const document = new Document({
|
||||
pageContent,
|
||||
metadata
|
||||
})
|
||||
|
||||
return [document]
|
||||
} catch (e: unknown) {
|
||||
throw new Error(`Failed to get YouTube video transcription: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { DocxLoader } from '@langchain/community/document_loaders/fs/docx'
|
||||
import { EPubLoader } from '@langchain/community/document_loaders/fs/epub'
|
||||
import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf'
|
||||
import { PPTXLoader } from '@langchain/community/document_loaders/fs/pptx'
|
||||
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio'
|
||||
import { SitemapLoader } from '@langchain/community/document_loaders/web/sitemap'
|
||||
import { FaissStore } from '@langchain/community/vectorstores/faiss'
|
||||
import { Document } from '@langchain/core/documents'
|
||||
import { loggerService } from '@logger'
|
||||
import { UrlSource } from '@main/utils/knowledge'
|
||||
import { LoaderReturn } from '@shared/config/types'
|
||||
import { FileMetadata, FileTypes, KnowledgeBaseParams } from '@types'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { JSONLoader } from 'langchain/document_loaders/fs/json'
|
||||
import { TextLoader } from 'langchain/document_loaders/fs/text'
|
||||
|
||||
import { SplitterFactory } from '../splitter'
|
||||
import { MarkdownLoader } from './MarkdownLoader'
|
||||
import { NoteLoader } from './NoteLoader'
|
||||
import { YoutubeLoader } from './YoutubeLoader'
|
||||
|
||||
const logger = loggerService.withContext('KnowledgeService File Loader')
|
||||
|
||||
type LoaderInstance =
|
||||
| TextLoader
|
||||
| PDFLoader
|
||||
| PPTXLoader
|
||||
| DocxLoader
|
||||
| JSONLoader
|
||||
| EPubLoader
|
||||
| CheerioWebBaseLoader
|
||||
| YoutubeLoader
|
||||
| SitemapLoader
|
||||
| NoteLoader
|
||||
| MarkdownLoader
|
||||
|
||||
/**
|
||||
* 为文档数组中的每个文档的 metadata 添加类型信息。
|
||||
*/
|
||||
function formatDocument(docs: Document[], type: string): Document[] {
|
||||
return docs.map((doc) => ({
|
||||
...doc,
|
||||
metadata: {
|
||||
...doc.metadata,
|
||||
type: type
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用文档处理管道
|
||||
*/
|
||||
async function processDocuments(
|
||||
base: KnowledgeBaseParams,
|
||||
vectorStore: FaissStore,
|
||||
docs: Document[],
|
||||
loaderType: string,
|
||||
splitterType?: string
|
||||
): Promise<LoaderReturn> {
|
||||
const formattedDocs = formatDocument(docs, loaderType)
|
||||
const splitter = SplitterFactory.create({
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap,
|
||||
...(splitterType && { type: splitterType })
|
||||
})
|
||||
|
||||
const splitterResults = await splitter.splitDocuments(formattedDocs)
|
||||
const ids = splitterResults.map(() => randomUUID())
|
||||
|
||||
await vectorStore.addDocuments(splitterResults, { ids })
|
||||
|
||||
return {
|
||||
entriesAdded: splitterResults.length,
|
||||
uniqueId: ids[0] || '',
|
||||
uniqueIds: ids,
|
||||
loaderType
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用加载器执行函数
|
||||
*/
|
||||
async function executeLoader(
|
||||
base: KnowledgeBaseParams,
|
||||
vectorStore: FaissStore,
|
||||
loaderInstance: LoaderInstance,
|
||||
loaderType: string,
|
||||
identifier: string,
|
||||
splitterType?: string
|
||||
): Promise<LoaderReturn> {
|
||||
const emptyResult: LoaderReturn = {
|
||||
entriesAdded: 0,
|
||||
uniqueId: '',
|
||||
uniqueIds: [],
|
||||
loaderType
|
||||
}
|
||||
|
||||
try {
|
||||
const docs = await loaderInstance.load()
|
||||
return await processDocuments(base, vectorStore, docs, loaderType, splitterType)
|
||||
} catch (error) {
|
||||
logger.error(`Error loading or processing ${identifier} with loader ${loaderType}: ${error}`)
|
||||
return emptyResult
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件扩展名到加载器的映射
|
||||
*/
|
||||
const FILE_LOADER_MAP: Record<string, { loader: new (path: string) => LoaderInstance; type: string }> = {
|
||||
'.pdf': { loader: PDFLoader, type: 'pdf' },
|
||||
'.txt': { loader: TextLoader, type: 'text' },
|
||||
'.pptx': { loader: PPTXLoader, type: 'pptx' },
|
||||
'.docx': { loader: DocxLoader, type: 'docx' },
|
||||
'.doc': { loader: DocxLoader, type: 'doc' },
|
||||
'.json': { loader: JSONLoader, type: 'json' },
|
||||
'.epub': { loader: EPubLoader, type: 'epub' },
|
||||
'.md': { loader: MarkdownLoader, type: 'markdown' }
|
||||
}
|
||||
|
||||
export async function addFileLoader(
|
||||
base: KnowledgeBaseParams,
|
||||
vectorStore: FaissStore,
|
||||
file: FileMetadata
|
||||
): Promise<LoaderReturn> {
|
||||
const fileExt = file.ext.toLowerCase()
|
||||
const loaderConfig = FILE_LOADER_MAP[fileExt]
|
||||
|
||||
if (!loaderConfig) {
|
||||
// 默认使用文本加载器
|
||||
const loaderInstance = new TextLoader(file.path)
|
||||
const type = fileExt.replace('.', '') || 'unknown'
|
||||
return executeLoader(base, vectorStore, loaderInstance, type, file.path)
|
||||
}
|
||||
|
||||
const loaderInstance = new loaderConfig.loader(file.path)
|
||||
return executeLoader(base, vectorStore, loaderInstance, loaderConfig.type, file.path)
|
||||
}
|
||||
|
||||
export async function addWebLoader(
|
||||
base: KnowledgeBaseParams,
|
||||
vectorStore: FaissStore,
|
||||
url: string,
|
||||
source: UrlSource
|
||||
): Promise<LoaderReturn> {
|
||||
let loaderInstance: CheerioWebBaseLoader | YoutubeLoader | undefined
|
||||
let splitterType: string | undefined
|
||||
|
||||
switch (source) {
|
||||
case 'normal':
|
||||
loaderInstance = new CheerioWebBaseLoader(url)
|
||||
break
|
||||
case 'youtube':
|
||||
loaderInstance = YoutubeLoader.createFromUrl(url, {
|
||||
addVideoInfo: true,
|
||||
transcriptFormat: 'srt'
|
||||
})
|
||||
splitterType = 'srt'
|
||||
break
|
||||
}
|
||||
|
||||
if (!loaderInstance) {
|
||||
return {
|
||||
entriesAdded: 0,
|
||||
uniqueId: '',
|
||||
uniqueIds: [],
|
||||
loaderType: source
|
||||
}
|
||||
}
|
||||
|
||||
return executeLoader(base, vectorStore, loaderInstance, source, url, splitterType)
|
||||
}
|
||||
|
||||
export async function addSitemapLoader(
|
||||
base: KnowledgeBaseParams,
|
||||
vectorStore: FaissStore,
|
||||
url: string
|
||||
): Promise<LoaderReturn> {
|
||||
const loaderInstance = new SitemapLoader(url)
|
||||
return executeLoader(base, vectorStore, loaderInstance, 'sitemap', url)
|
||||
}
|
||||
|
||||
export async function addNoteLoader(
|
||||
base: KnowledgeBaseParams,
|
||||
vectorStore: FaissStore,
|
||||
content: string,
|
||||
sourceUrl: string
|
||||
): Promise<LoaderReturn> {
|
||||
const loaderInstance = new NoteLoader(content, sourceUrl)
|
||||
return executeLoader(base, vectorStore, loaderInstance, 'note', sourceUrl)
|
||||
}
|
||||
|
||||
export async function addVideoLoader(
|
||||
base: KnowledgeBaseParams,
|
||||
vectorStore: FaissStore,
|
||||
files: FileMetadata[]
|
||||
): Promise<LoaderReturn> {
|
||||
const srtFile = files.find((f) => f.type === FileTypes.TEXT)
|
||||
const videoFile = files.find((f) => f.type === FileTypes.VIDEO)
|
||||
|
||||
const emptyResult: LoaderReturn = {
|
||||
entriesAdded: 0,
|
||||
uniqueId: '',
|
||||
uniqueIds: [],
|
||||
loaderType: 'video'
|
||||
}
|
||||
|
||||
if (!srtFile || !videoFile) {
|
||||
return emptyResult
|
||||
}
|
||||
|
||||
try {
|
||||
const loaderInstance = new TextLoader(srtFile.path)
|
||||
const originalDocs = await loaderInstance.load()
|
||||
|
||||
const docsWithVideoMeta = originalDocs.map(
|
||||
(doc) =>
|
||||
new Document({
|
||||
...doc,
|
||||
metadata: {
|
||||
...doc.metadata,
|
||||
video: {
|
||||
path: videoFile.path,
|
||||
name: videoFile.origin_name
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return await processDocuments(base, vectorStore, docsWithVideoMeta, 'video', 'srt')
|
||||
} catch (error) {
|
||||
logger.error(`Error loading or processing file ${srtFile.path} with loader video: ${error}`)
|
||||
return emptyResult
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { BM25Retriever } from '@langchain/community/retrievers/bm25'
|
||||
import { FaissStore } from '@langchain/community/vectorstores/faiss'
|
||||
import { BaseRetriever } from '@langchain/core/retrievers'
|
||||
import { loggerService } from '@main/services/LoggerService'
|
||||
import { type KnowledgeBaseParams } from '@types'
|
||||
import { type Document } from 'langchain/document'
|
||||
import { EnsembleRetriever } from 'langchain/retrievers/ensemble'
|
||||
|
||||
const logger = loggerService.withContext('RetrieverFactory')
|
||||
export class RetrieverFactory {
|
||||
/**
|
||||
* 根据提供的参数创建一个 LangChain 检索器 (Retriever)。
|
||||
* @param base 知识库配置参数。
|
||||
* @param vectorStore 一个已初始化的向量存储实例。
|
||||
* @param documents 文档列表,用于初始化 BM25Retriever。
|
||||
* @returns 返回一个 BaseRetriever 实例。
|
||||
*/
|
||||
public createRetriever(base: KnowledgeBaseParams, vectorStore: FaissStore, documents: Document[]): BaseRetriever {
|
||||
const retrieverType = base.retriever?.mode ?? 'hybrid'
|
||||
const retrieverWeight = base.retriever?.weight ?? 0.5
|
||||
const searchK = base.documentCount ?? 5
|
||||
|
||||
logger.info(`Creating retriever of type: ${retrieverType} with k=${searchK}`)
|
||||
|
||||
switch (retrieverType) {
|
||||
case 'bm25':
|
||||
if (documents.length === 0) {
|
||||
throw new Error('BM25Retriever requires documents, but none were provided or found.')
|
||||
}
|
||||
logger.info('Create BM25 Retriever')
|
||||
return BM25Retriever.fromDocuments(documents, { k: searchK })
|
||||
|
||||
case 'hybrid': {
|
||||
if (documents.length === 0) {
|
||||
logger.warn('No documents provided for BM25 part of hybrid search. Falling back to vector search only.')
|
||||
return vectorStore.asRetriever(searchK)
|
||||
}
|
||||
|
||||
const vectorstoreRetriever = vectorStore.asRetriever(searchK)
|
||||
const bm25Retriever = BM25Retriever.fromDocuments(documents, { k: searchK })
|
||||
|
||||
logger.info('Create Hybrid Retriever')
|
||||
return new EnsembleRetriever({
|
||||
retrievers: [bm25Retriever, vectorstoreRetriever],
|
||||
weights: [retrieverWeight, 1 - retrieverWeight]
|
||||
})
|
||||
}
|
||||
|
||||
case 'vector':
|
||||
default:
|
||||
logger.info('Create Vector Retriever')
|
||||
return vectorStore.asRetriever(searchK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { Document } from '@langchain/core/documents'
|
||||
import { TextSplitter, TextSplitterParams } from 'langchain/text_splitter'
|
||||
|
||||
// 定义一个接口来表示解析后的单个字幕片段
|
||||
interface SrtSegment {
|
||||
text: string
|
||||
startTime: number // in seconds
|
||||
endTime: number // in seconds
|
||||
}
|
||||
|
||||
// 辅助函数:将 SRT 时间戳字符串 (HH:MM:SS,ms) 转换为秒
|
||||
function srtTimeToSeconds(time: string): number {
|
||||
const parts = time.split(':')
|
||||
const secondsAndMs = parts[2].split(',')
|
||||
const hours = parseInt(parts[0], 10)
|
||||
const minutes = parseInt(parts[1], 10)
|
||||
const seconds = parseInt(secondsAndMs[0], 10)
|
||||
const milliseconds = parseInt(secondsAndMs[1], 10)
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000
|
||||
}
|
||||
|
||||
export class SrtSplitter extends TextSplitter {
|
||||
constructor(fields?: Partial<TextSplitterParams>) {
|
||||
// 传入 chunkSize 和 chunkOverlap
|
||||
super(fields)
|
||||
}
|
||||
splitText(): Promise<string[]> {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
|
||||
// 核心方法:重写 splitDocuments 来实现自定义逻辑
|
||||
async splitDocuments(documents: Document[]): Promise<Document[]> {
|
||||
const allChunks: Document[] = []
|
||||
|
||||
for (const doc of documents) {
|
||||
// 1. 解析 SRT 内容
|
||||
const segments = this.parseSrt(doc.pageContent)
|
||||
if (segments.length === 0) continue
|
||||
|
||||
// 2. 将字幕片段组合成块
|
||||
const chunks = this.mergeSegmentsIntoChunks(segments, doc.metadata)
|
||||
allChunks.push(...chunks)
|
||||
}
|
||||
|
||||
return allChunks
|
||||
}
|
||||
|
||||
// 辅助方法:解析整个 SRT 字符串
|
||||
private parseSrt(srt: string): SrtSegment[] {
|
||||
const segments: SrtSegment[] = []
|
||||
const blocks = srt.trim().split(/\n\n/)
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n')
|
||||
if (lines.length < 3) continue
|
||||
|
||||
const timeMatch = lines[1].match(/(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})/)
|
||||
if (!timeMatch) continue
|
||||
|
||||
const startTime = srtTimeToSeconds(timeMatch[1])
|
||||
const endTime = srtTimeToSeconds(timeMatch[2])
|
||||
const text = lines.slice(2).join(' ').trim()
|
||||
|
||||
segments.push({ text, startTime, endTime })
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
// 辅助方法:将解析后的片段合并成每 5 段一个块
|
||||
private mergeSegmentsIntoChunks(segments: SrtSegment[], baseMetadata: Record<string, any>): Document[] {
|
||||
const chunks: Document[] = []
|
||||
let currentChunkText = ''
|
||||
let currentChunkStartTime = 0
|
||||
let currentChunkEndTime = 0
|
||||
let segmentCount = 0
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segmentCount === 0) {
|
||||
currentChunkStartTime = segment.startTime
|
||||
}
|
||||
|
||||
currentChunkText += (currentChunkText ? ' ' : '') + segment.text
|
||||
currentChunkEndTime = segment.endTime
|
||||
segmentCount++
|
||||
|
||||
// 当累积到 5 段时,创建一个新的 Document
|
||||
if (segmentCount === 5) {
|
||||
const metadata: Record<string, any> = {
|
||||
...baseMetadata,
|
||||
startTime: currentChunkStartTime,
|
||||
endTime: currentChunkEndTime
|
||||
}
|
||||
if (baseMetadata.source_url) {
|
||||
metadata.source_url_with_timestamp = `${baseMetadata.source_url}?t=${Math.floor(currentChunkStartTime)}s`
|
||||
}
|
||||
chunks.push(
|
||||
new Document({
|
||||
pageContent: currentChunkText,
|
||||
metadata
|
||||
})
|
||||
)
|
||||
|
||||
// 重置计数器和临时变量
|
||||
currentChunkText = ''
|
||||
currentChunkStartTime = 0
|
||||
currentChunkEndTime = 0
|
||||
segmentCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 如果还有剩余的片段,创建最后一个 Document
|
||||
if (segmentCount > 0) {
|
||||
const metadata: Record<string, any> = {
|
||||
...baseMetadata,
|
||||
startTime: currentChunkStartTime,
|
||||
endTime: currentChunkEndTime
|
||||
}
|
||||
if (baseMetadata.source_url) {
|
||||
metadata.source_url_with_timestamp = `${baseMetadata.source_url}?t=${Math.floor(currentChunkStartTime)}s`
|
||||
}
|
||||
chunks.push(
|
||||
new Document({
|
||||
pageContent: currentChunkText,
|
||||
metadata
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { RecursiveCharacterTextSplitter, TextSplitter } from '@langchain/textsplitters'
|
||||
|
||||
import { SrtSplitter } from './SrtSplitter'
|
||||
|
||||
export type SplitterConfig = {
|
||||
chunkSize?: number
|
||||
chunkOverlap?: number
|
||||
type?: 'recursive' | 'srt' | string
|
||||
}
|
||||
export class SplitterFactory {
|
||||
/**
|
||||
* Creates a TextSplitter instance based on the provided configuration.
|
||||
* @param config - The configuration object specifying the splitter type and its parameters.
|
||||
* @returns An instance of a TextSplitter, or null if no splitting is required.
|
||||
*/
|
||||
public static create(config: SplitterConfig): TextSplitter {
|
||||
switch (config.type) {
|
||||
case 'srt':
|
||||
return new SrtSplitter({
|
||||
chunkSize: config.chunkSize,
|
||||
chunkOverlap: config.chunkOverlap
|
||||
})
|
||||
case 'recursive':
|
||||
default:
|
||||
return new RecursiveCharacterTextSplitter({
|
||||
chunkSize: config.chunkSize,
|
||||
chunkOverlap: config.chunkOverlap
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import PreprocessProvider from '@main/knowledge/preprocess/PreprocessProvider'
|
||||
import { loggerService } from '@main/services/LoggerService'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import type { FileMetadata, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
||||
|
||||
const logger = loggerService.withContext('PreprocessingService')
|
||||
|
||||
class PreprocessingService {
|
||||
public async preprocessFile(
|
||||
file: FileMetadata,
|
||||
base: KnowledgeBaseParams,
|
||||
item: KnowledgeItem,
|
||||
userId: string
|
||||
): Promise<FileMetadata> {
|
||||
let fileToProcess: FileMetadata = file
|
||||
// Check if preprocessing is configured and applicable (e.g., for PDFs)
|
||||
if (base.preprocessProvider && file.ext.toLowerCase() === '.pdf') {
|
||||
try {
|
||||
const provider = new PreprocessProvider(base.preprocessProvider.provider, userId)
|
||||
|
||||
// Check if file has already been preprocessed
|
||||
const alreadyProcessed = await provider.checkIfAlreadyProcessed(file)
|
||||
if (alreadyProcessed) {
|
||||
logger.debug(`File already preprocessed, using cached result: ${file.path}`)
|
||||
return alreadyProcessed
|
||||
}
|
||||
|
||||
// Execute preprocessing
|
||||
logger.debug(`Starting preprocess for scanned PDF: ${file.path}`)
|
||||
const { processedFile, quota } = await provider.parseFile(item.id, file)
|
||||
fileToProcess = processedFile
|
||||
|
||||
// Notify the UI
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
mainWindow?.webContents.send('file-preprocess-finished', {
|
||||
itemId: item.id,
|
||||
quota: quota
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error(`Preprocessing failed: ${err}`)
|
||||
// If preprocessing fails, re-throw the error to be handled by the caller
|
||||
throw new Error(`Preprocessing failed: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
return fileToProcess
|
||||
}
|
||||
|
||||
public async checkQuota(base: KnowledgeBaseParams, userId: string): Promise<number> {
|
||||
try {
|
||||
if (base.preprocessProvider && base.preprocessProvider.type === 'preprocess') {
|
||||
const provider = new PreprocessProvider(base.preprocessProvider.provider, userId)
|
||||
return await provider.checkQuota()
|
||||
}
|
||||
throw new Error('No preprocess provider configured')
|
||||
} catch (err) {
|
||||
logger.error(`Failed to check quota: ${err}`)
|
||||
throw new Error(`Failed to check quota: ${err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const preprocessingService = new PreprocessingService()
|
||||
@@ -1,46 +1,101 @@
|
||||
import { DEFAULT_DOCUMENT_COUNT, DEFAULT_RELEVANT_SCORE } from '@main/utils/knowledge'
|
||||
import { KnowledgeBaseParams, KnowledgeSearchResult } from '@types'
|
||||
|
||||
import { MultiModalDocument, RerankStrategy } from './strategies/RerankStrategy'
|
||||
import { StrategyFactory } from './strategies/StrategyFactory'
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
|
||||
export default abstract class BaseReranker {
|
||||
protected base: KnowledgeBaseParams
|
||||
protected strategy: RerankStrategy
|
||||
|
||||
constructor(base: KnowledgeBaseParams) {
|
||||
if (!base.rerankApiClient) {
|
||||
throw new Error('Rerank model is required')
|
||||
}
|
||||
this.base = base
|
||||
this.strategy = StrategyFactory.createStrategy(base.rerankApiClient.provider)
|
||||
}
|
||||
abstract rerank(query: string, searchResults: KnowledgeSearchResult[]): Promise<KnowledgeSearchResult[]>
|
||||
protected getRerankUrl(): string {
|
||||
return this.strategy.buildUrl(this.base.rerankApiClient?.baseURL)
|
||||
}
|
||||
protected getRerankRequestBody(query: string, searchResults: KnowledgeSearchResult[]) {
|
||||
const documents = this.buildDocuments(searchResults)
|
||||
const topN = this.base.documentCount ?? DEFAULT_DOCUMENT_COUNT
|
||||
const model = this.base.rerankApiClient?.model
|
||||
return this.strategy.buildRequestBody(query, documents, topN, model)
|
||||
}
|
||||
private buildDocuments(searchResults: KnowledgeSearchResult[]): MultiModalDocument[] {
|
||||
return searchResults.map((doc) => {
|
||||
const document: MultiModalDocument = {}
|
||||
|
||||
// 检查是否是图片类型,添加图片内容
|
||||
if (doc.metadata?.type === 'image') {
|
||||
document.image = doc.pageContent
|
||||
} else {
|
||||
document.text = doc.pageContent
|
||||
abstract rerank(query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]>
|
||||
|
||||
/**
|
||||
* Get Rerank Request Url
|
||||
*/
|
||||
protected getRerankUrl() {
|
||||
if (this.base.rerankApiClient?.provider === 'bailian') {
|
||||
return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank'
|
||||
}
|
||||
|
||||
let baseURL = this.base.rerankApiClient?.baseURL
|
||||
|
||||
if (baseURL && baseURL.endsWith('/')) {
|
||||
// `/` 结尾强制使用rerankBaseURL
|
||||
return `${baseURL}rerank`
|
||||
}
|
||||
|
||||
if (baseURL && !baseURL.endsWith('/v1')) {
|
||||
baseURL = `${baseURL}/v1`
|
||||
}
|
||||
|
||||
return `${baseURL}/rerank`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Rerank Request Body
|
||||
*/
|
||||
protected getRerankRequestBody(query: string, searchResults: ExtractChunkData[]) {
|
||||
const provider = this.base.rerankApiClient?.provider
|
||||
const documents = searchResults.map((doc) => doc.pageContent)
|
||||
const topN = this.base.documentCount
|
||||
|
||||
if (provider === 'voyageai') {
|
||||
return {
|
||||
model: this.base.rerankApiClient?.model,
|
||||
query,
|
||||
documents,
|
||||
top_k: topN
|
||||
}
|
||||
|
||||
return document
|
||||
})
|
||||
} else if (provider === 'bailian') {
|
||||
return {
|
||||
model: this.base.rerankApiClient?.model,
|
||||
input: {
|
||||
query,
|
||||
documents
|
||||
},
|
||||
parameters: {
|
||||
top_n: topN
|
||||
}
|
||||
}
|
||||
} else if (provider?.includes('tei')) {
|
||||
return {
|
||||
query,
|
||||
texts: documents,
|
||||
return_text: true
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
model: this.base.rerankApiClient?.model,
|
||||
query,
|
||||
documents,
|
||||
top_n: topN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Rerank Result
|
||||
*/
|
||||
protected extractRerankResult(data: any) {
|
||||
return this.strategy.extractResults(data)
|
||||
const provider = this.base.rerankApiClient?.provider
|
||||
if (provider === 'bailian') {
|
||||
return data.output.results
|
||||
} else if (provider === 'voyageai') {
|
||||
return data.data
|
||||
} else if (provider?.includes('tei')) {
|
||||
return data.map((item: any) => {
|
||||
return {
|
||||
index: item.index,
|
||||
relevance_score: item.score
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return data.results
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,30 +105,35 @@ export default abstract class BaseReranker {
|
||||
* @protected
|
||||
*/
|
||||
protected getRerankResult(
|
||||
searchResults: KnowledgeSearchResult[],
|
||||
rerankResults: Array<{ index: number; relevance_score: number }>
|
||||
searchResults: ExtractChunkData[],
|
||||
rerankResults: Array<{
|
||||
index: number
|
||||
relevance_score: number
|
||||
}>
|
||||
) {
|
||||
const resultMap = new Map(
|
||||
rerankResults.map((result) => [result.index, result.relevance_score || DEFAULT_RELEVANT_SCORE])
|
||||
)
|
||||
const resultMap = new Map(rerankResults.map((result) => [result.index, result.relevance_score || 0]))
|
||||
|
||||
const returenResults = searchResults
|
||||
.map((doc: KnowledgeSearchResult, index: number) => {
|
||||
return searchResults
|
||||
.map((doc: ExtractChunkData, index: number) => {
|
||||
const score = resultMap.get(index)
|
||||
if (score === undefined) return undefined
|
||||
return { ...doc, score }
|
||||
})
|
||||
.filter((doc): doc is KnowledgeSearchResult => doc !== undefined)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
return returenResults
|
||||
return {
|
||||
...doc,
|
||||
score
|
||||
}
|
||||
})
|
||||
.filter((doc): doc is ExtractChunkData => doc !== undefined)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
|
||||
public defaultHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.base.rerankApiClient?.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
protected formatErrorMessage(url: string, error: any, requestBody: any) {
|
||||
const errorDetails = {
|
||||
url: url,
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { KnowledgeBaseParams, KnowledgeSearchResult } from '@types'
|
||||
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
import { net } from 'electron'
|
||||
|
||||
import BaseReranker from './BaseReranker'
|
||||
|
||||
export default class GeneralReranker extends BaseReranker {
|
||||
constructor(base: KnowledgeBaseParams) {
|
||||
super(base)
|
||||
}
|
||||
public rerank = async (query: string, searchResults: KnowledgeSearchResult[]): Promise<KnowledgeSearchResult[]> => {
|
||||
|
||||
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
|
||||
const url = this.getRerankUrl()
|
||||
|
||||
const requestBody = this.getRerankRequestBody(query, searchResults)
|
||||
|
||||
try {
|
||||
const response = await net.fetch(url, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { KnowledgeBaseParams, KnowledgeSearchResult } from '@types'
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
|
||||
import GeneralReranker from './GeneralReranker'
|
||||
|
||||
@@ -7,7 +8,7 @@ export default class Reranker {
|
||||
constructor(base: KnowledgeBaseParams) {
|
||||
this.sdk = new GeneralReranker(base)
|
||||
}
|
||||
public async rerank(query: string, searchResults: KnowledgeSearchResult[]): Promise<KnowledgeSearchResult[]> {
|
||||
public async rerank(query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> {
|
||||
return this.sdk.rerank(query, searchResults)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { MultiModalDocument, RerankStrategy } from './RerankStrategy'
|
||||
export class BailianStrategy implements RerankStrategy {
|
||||
buildUrl(): string {
|
||||
return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank'
|
||||
}
|
||||
buildRequestBody(query: string, documents: MultiModalDocument[], topN: number, model?: string) {
|
||||
const textDocuments = documents.filter((d) => d.text).map((d) => d.text!)
|
||||
|
||||
return {
|
||||
model,
|
||||
input: { query, documents: textDocuments },
|
||||
parameters: { top_n: topN }
|
||||
}
|
||||
}
|
||||
extractResults(data: any) {
|
||||
return data.output.results
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { MultiModalDocument, RerankStrategy } from './RerankStrategy'
|
||||
export class DefaultStrategy implements RerankStrategy {
|
||||
buildUrl(baseURL?: string): string {
|
||||
if (baseURL && baseURL.endsWith('/')) {
|
||||
return `${baseURL}rerank`
|
||||
}
|
||||
if (baseURL && !baseURL.endsWith('/v1')) {
|
||||
baseURL = `${baseURL}/v1`
|
||||
}
|
||||
return `${baseURL}/rerank`
|
||||
}
|
||||
buildRequestBody(query: string, documents: MultiModalDocument[], topN: number, model?: string) {
|
||||
const textDocuments = documents.filter((d) => d.text).map((d) => d.text!)
|
||||
|
||||
return {
|
||||
model,
|
||||
query,
|
||||
documents: textDocuments,
|
||||
top_n: topN
|
||||
}
|
||||
}
|
||||
extractResults(data: any) {
|
||||
return data.results
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { MultiModalDocument, RerankStrategy } from './RerankStrategy'
|
||||
export class JinaStrategy implements RerankStrategy {
|
||||
buildUrl(baseURL?: string): string {
|
||||
if (baseURL && baseURL.endsWith('/')) {
|
||||
return `${baseURL}rerank`
|
||||
}
|
||||
if (baseURL && !baseURL.endsWith('/v1')) {
|
||||
baseURL = `${baseURL}/v1`
|
||||
}
|
||||
return `${baseURL}/rerank`
|
||||
}
|
||||
buildRequestBody(query: string, documents: MultiModalDocument[], topN: number, model?: string) {
|
||||
if (model === 'jina-reranker-m0') {
|
||||
return {
|
||||
model,
|
||||
query,
|
||||
documents,
|
||||
top_n: topN
|
||||
}
|
||||
}
|
||||
const textDocuments = documents.filter((d) => d.text).map((d) => d.text!)
|
||||
|
||||
return {
|
||||
model,
|
||||
query,
|
||||
documents: textDocuments,
|
||||
top_n: topN
|
||||
}
|
||||
}
|
||||
extractResults(data: any) {
|
||||
return data.results
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface MultiModalDocument {
|
||||
text?: string
|
||||
image?: string
|
||||
}
|
||||
export interface RerankStrategy {
|
||||
buildUrl(baseURL?: string): string
|
||||
buildRequestBody(query: string, documents: MultiModalDocument[], topN: number, model?: string): any
|
||||
extractResults(data: any): Array<{ index: number; relevance_score: number }>
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { BailianStrategy } from './BailianStrategy'
|
||||
import { DefaultStrategy } from './DefaultStrategy'
|
||||
import { JinaStrategy } from './JinaStrategy'
|
||||
import { RerankStrategy } from './RerankStrategy'
|
||||
import { TEIStrategy } from './TeiStrategy'
|
||||
import { isTEIProvider, RERANKER_PROVIDERS } from './types'
|
||||
import { VoyageAIStrategy } from './VoyageStrategy'
|
||||
|
||||
export class StrategyFactory {
|
||||
static createStrategy(provider?: string): RerankStrategy {
|
||||
switch (provider) {
|
||||
case RERANKER_PROVIDERS.VOYAGEAI:
|
||||
return new VoyageAIStrategy()
|
||||
case RERANKER_PROVIDERS.BAILIAN:
|
||||
return new BailianStrategy()
|
||||
case RERANKER_PROVIDERS.JINA:
|
||||
return new JinaStrategy()
|
||||
default:
|
||||
if (isTEIProvider(provider)) {
|
||||
return new TEIStrategy()
|
||||
}
|
||||
return new DefaultStrategy()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { MultiModalDocument, RerankStrategy } from './RerankStrategy'
|
||||
export class TEIStrategy implements RerankStrategy {
|
||||
buildUrl(baseURL?: string): string {
|
||||
if (baseURL && baseURL.endsWith('/')) {
|
||||
return `${baseURL}rerank`
|
||||
}
|
||||
if (baseURL && !baseURL.endsWith('/v1')) {
|
||||
baseURL = `${baseURL}/v1`
|
||||
}
|
||||
return `${baseURL}/rerank`
|
||||
}
|
||||
buildRequestBody(query: string, documents: MultiModalDocument[]) {
|
||||
const textDocuments = documents.filter((d) => d.text).map((d) => d.text!)
|
||||
return {
|
||||
query,
|
||||
texts: textDocuments,
|
||||
return_text: true
|
||||
}
|
||||
}
|
||||
extractResults(data: any) {
|
||||
return data.map((item: any) => ({
|
||||
index: item.index,
|
||||
relevance_score: item.score
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { MultiModalDocument, RerankStrategy } from './RerankStrategy'
|
||||
export class VoyageAIStrategy implements RerankStrategy {
|
||||
buildUrl(baseURL?: string): string {
|
||||
if (baseURL && baseURL.endsWith('/')) {
|
||||
return `${baseURL}rerank`
|
||||
}
|
||||
if (baseURL && !baseURL.endsWith('/v1')) {
|
||||
baseURL = `${baseURL}/v1`
|
||||
}
|
||||
return `${baseURL}/rerank`
|
||||
}
|
||||
buildRequestBody(query: string, documents: MultiModalDocument[], topN: number, model?: string) {
|
||||
const textDocuments = documents.filter((d) => d.text).map((d) => d.text!)
|
||||
return {
|
||||
model,
|
||||
query,
|
||||
documents: textDocuments,
|
||||
top_k: topN
|
||||
}
|
||||
}
|
||||
extractResults(data: any) {
|
||||
return data.data
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { objectValues } from '@types'
|
||||
|
||||
export const RERANKER_PROVIDERS = {
|
||||
VOYAGEAI: 'voyageai',
|
||||
BAILIAN: 'bailian',
|
||||
JINA: 'jina',
|
||||
TEI: 'tei'
|
||||
} as const
|
||||
|
||||
export type RerankProvider = (typeof RERANKER_PROVIDERS)[keyof typeof RERANKER_PROVIDERS]
|
||||
|
||||
export function isTEIProvider(provider?: string): boolean {
|
||||
return provider?.includes(RERANKER_PROVIDERS.TEI) ?? false
|
||||
}
|
||||
|
||||
export function isKnownProvider(provider?: string): provider is RerankProvider {
|
||||
if (!provider) return false
|
||||
return objectValues(RERANKER_PROVIDERS).some((p) => p === provider)
|
||||
}
|
||||
158
src/main/services/ClaudeCodeService.ts
Normal file
158
src/main/services/ClaudeCodeService.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { createExecutor } from '@cherrystudio/ai-core'
|
||||
import { loggerService } from '@logger'
|
||||
import { createClaudeCode } from 'ai-sdk-provider-claude-code'
|
||||
import express, { Request, Response } from 'express'
|
||||
import { Server } from 'http'
|
||||
|
||||
const logger = loggerService.withContext('ClaudeCodeService')
|
||||
|
||||
export class ClaudeCodeService {
|
||||
private app: express.Application
|
||||
private server: Server | null = null
|
||||
private port: number = 0
|
||||
private claudeCodeProvider: any = null
|
||||
|
||||
constructor() {
|
||||
this.app = express()
|
||||
this.setupMiddleware()
|
||||
this.setupRoutes()
|
||||
}
|
||||
|
||||
private setupMiddleware() {
|
||||
this.app.use(express.json())
|
||||
this.app.use(express.text())
|
||||
}
|
||||
|
||||
private setupRoutes() {
|
||||
// Health check endpoint
|
||||
this.app.get('/health', (_req: Request, res: Response) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() })
|
||||
})
|
||||
|
||||
// Initialize claude-code provider
|
||||
this.app.post('/init', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const config = req.body
|
||||
logger.info('Initializing claude-code provider with config', config)
|
||||
|
||||
this.claudeCodeProvider = createClaudeCode()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Claude-code provider initialized successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize claude-code provider', error as Error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: (error as Error).message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Stream text completion endpoint
|
||||
this.app.post('/completions', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!this.claudeCodeProvider) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Claude-code provider not initialized. Call /init first.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const { modelId, params, options } = req.body
|
||||
logger.info('Processing completions request', { modelId, hasParams: !!params })
|
||||
|
||||
// 创建执行器
|
||||
const executor = createExecutor('claude-code', options || {}, [])
|
||||
const model = this.claudeCodeProvider.languageModel('opus')
|
||||
|
||||
// 执行流式文本生成
|
||||
const result = await executor.streamText({
|
||||
...params,
|
||||
model,
|
||||
abortSignal: new AbortController().signal
|
||||
})
|
||||
console.log('result', result)
|
||||
// 使用 AI SDK 提供的便捷函数处理流式响应
|
||||
result.pipeUIMessageStreamToResponse(res)
|
||||
|
||||
logger.info('Completions request completed successfully')
|
||||
} catch (error) {
|
||||
logger.error('Error in completions endpoint', error as Error)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: (error as Error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public async start(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 尝试使用固定端口,如果失败则使用系统分配端口
|
||||
const preferredPort = 23456
|
||||
|
||||
this.server = this.app.listen(preferredPort, 'localhost', () => {
|
||||
if (this.server?.address()) {
|
||||
this.port = (this.server.address() as any)?.port || 0
|
||||
logger.info(`Claude-code HTTP service started on port ${this.port}`)
|
||||
resolve(this.port)
|
||||
} else {
|
||||
reject(new Error('Failed to start server'))
|
||||
}
|
||||
})
|
||||
|
||||
this.server.on('error', (error: any) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
logger.warn(`Port ${preferredPort} is in use, trying with dynamic port`)
|
||||
// 如果固定端口被占用,使用动态端口
|
||||
this.server = this.app.listen(0, 'localhost', () => {
|
||||
if (this.server?.address()) {
|
||||
this.port = (this.server.address() as any)?.port || 0
|
||||
logger.info(`Claude-code HTTP service started on dynamic port ${this.port}`)
|
||||
resolve(this.port)
|
||||
} else {
|
||||
reject(new Error('Failed to start server'))
|
||||
}
|
||||
})
|
||||
|
||||
this.server.on('error', (dynamicError) => {
|
||||
logger.error('Server error on dynamic port', dynamicError)
|
||||
reject(dynamicError)
|
||||
})
|
||||
} else {
|
||||
logger.error('Server error', error)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.server) {
|
||||
this.server.close(() => {
|
||||
logger.info('Claude-code HTTP service stopped')
|
||||
resolve()
|
||||
})
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public getPort(): number {
|
||||
return this.port
|
||||
}
|
||||
|
||||
public isRunning(): boolean {
|
||||
return this.server !== null && this.server.listening
|
||||
}
|
||||
}
|
||||
|
||||
// 单例实例
|
||||
export const claudeCodeService = new ClaudeCodeService()
|
||||
@@ -1,40 +1,111 @@
|
||||
/**
|
||||
* Knowledge Service - Manages knowledge bases using RAG (Retrieval-Augmented Generation)
|
||||
*
|
||||
* This service handles creation, management, and querying of knowledge bases from various sources
|
||||
* including files, directories, URLs, sitemaps, and notes.
|
||||
*
|
||||
* Features:
|
||||
* - Concurrent task processing with workload management
|
||||
* - Multiple data source support
|
||||
* - Vector database integration
|
||||
*
|
||||
* For detailed documentation, see:
|
||||
* @see {@link ../../../docs/technical/KnowledgeService.md}
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { RAGApplication, RAGApplicationBuilder } from '@cherrystudio/embedjs'
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { LibSqlDb } from '@cherrystudio/embedjs-libsql'
|
||||
import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap'
|
||||
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
||||
import { loggerService } from '@logger'
|
||||
import Embeddings from '@main/knowledge/embedjs/embeddings/Embeddings'
|
||||
import { addFileLoader } from '@main/knowledge/embedjs/loader'
|
||||
import { NoteLoader } from '@main/knowledge/embedjs/loader/noteLoader'
|
||||
import { preprocessingService } from '@main/knowledge/preprocess/PreprocessingService'
|
||||
import Embeddings from '@main/knowledge/embeddings/Embeddings'
|
||||
import { addFileLoader } from '@main/knowledge/loader'
|
||||
import { NoteLoader } from '@main/knowledge/loader/noteLoader'
|
||||
import PreprocessProvider from '@main/knowledge/preprocess/PreprocessProvider'
|
||||
import Reranker from '@main/knowledge/reranker/Reranker'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
import { TraceMethod } from '@mcp-trace/trace-core'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { LoaderReturn } from '@shared/config/types'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileMetadata, KnowledgeBaseParams, KnowledgeSearchResult } from '@types'
|
||||
import { FileMetadata, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { windowService } from '../WindowService'
|
||||
import {
|
||||
IKnowledgeFramework,
|
||||
KnowledgeBaseAddItemOptionsNonNullableAttribute,
|
||||
LoaderDoneReturn,
|
||||
LoaderTask,
|
||||
LoaderTaskItem,
|
||||
LoaderTaskItemState
|
||||
} from './IKnowledgeFramework'
|
||||
|
||||
const logger = loggerService.withContext('MainKnowledgeService')
|
||||
|
||||
export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
private storageDir: string
|
||||
private ragApplications: Map<string, RAGApplication> = new Map()
|
||||
private pendingDeleteFile: string
|
||||
private dbInstances: Map<string, LibSqlDb> = new Map()
|
||||
export interface KnowledgeBaseAddItemOptions {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload?: boolean
|
||||
userId?: string
|
||||
}
|
||||
|
||||
interface KnowledgeBaseAddItemOptionsNonNullableAttribute {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload: boolean
|
||||
userId: string
|
||||
}
|
||||
|
||||
interface EvaluateTaskWorkload {
|
||||
workload: number
|
||||
}
|
||||
|
||||
type LoaderDoneReturn = LoaderReturn | null
|
||||
|
||||
enum LoaderTaskItemState {
|
||||
PENDING,
|
||||
PROCESSING,
|
||||
DONE
|
||||
}
|
||||
|
||||
interface LoaderTaskItem {
|
||||
state: LoaderTaskItemState
|
||||
task: () => Promise<unknown>
|
||||
evaluateTaskWorkload: EvaluateTaskWorkload
|
||||
}
|
||||
|
||||
interface LoaderTask {
|
||||
loaderTasks: LoaderTaskItem[]
|
||||
loaderDoneReturn: LoaderDoneReturn
|
||||
}
|
||||
|
||||
interface LoaderTaskOfSet {
|
||||
loaderTasks: Set<LoaderTaskItem>
|
||||
loaderDoneReturn: LoaderDoneReturn
|
||||
}
|
||||
|
||||
interface QueueTaskItem {
|
||||
taskPromise: () => Promise<unknown>
|
||||
resolve: () => void
|
||||
evaluateTaskWorkload: EvaluateTaskWorkload
|
||||
}
|
||||
|
||||
const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => {
|
||||
return {
|
||||
loaderTasks: new Set(loaderTask.loaderTasks),
|
||||
loaderDoneReturn: loaderTask.loaderDoneReturn
|
||||
}
|
||||
}
|
||||
|
||||
class KnowledgeService {
|
||||
private storageDir = path.join(getDataPath(), 'KnowledgeBase')
|
||||
private pendingDeleteFile = path.join(this.storageDir, 'knowledge_pending_delete.json')
|
||||
// Byte based
|
||||
private workload = 0
|
||||
private processingItemCount = 0
|
||||
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
|
||||
private ragApplications: Map<string, RAGApplication> = new Map()
|
||||
private dbInstances: Map<string, LibSqlDb> = new Map()
|
||||
private static MAXIMUM_WORKLOAD = 80 * MB
|
||||
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
|
||||
private static ERROR_LOADER_RETURN: LoaderReturn = {
|
||||
entriesAdded: 0,
|
||||
uniqueId: '',
|
||||
@@ -43,9 +114,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
status: 'failed'
|
||||
}
|
||||
|
||||
constructor(storageDir: string) {
|
||||
this.storageDir = storageDir
|
||||
this.pendingDeleteFile = path.join(this.storageDir, 'knowledge_pending_delete.json')
|
||||
constructor() {
|
||||
this.initStorageDir()
|
||||
this.cleanupOnStartup()
|
||||
}
|
||||
@@ -160,28 +229,33 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
logger.info(`Startup cleanup completed: ${deletedCount}/${pendingDeleteIds.length} knowledge bases deleted`)
|
||||
}
|
||||
|
||||
private async getRagApplication(base: KnowledgeBaseParams): Promise<RAGApplication> {
|
||||
if (this.ragApplications.has(base.id)) {
|
||||
return this.ragApplications.get(base.id)!
|
||||
private getRagApplication = async ({
|
||||
id,
|
||||
embedApiClient,
|
||||
dimensions,
|
||||
documentCount
|
||||
}: KnowledgeBaseParams): Promise<RAGApplication> => {
|
||||
if (this.ragApplications.has(id)) {
|
||||
return this.ragApplications.get(id)!
|
||||
}
|
||||
|
||||
let ragApplication: RAGApplication
|
||||
const embeddings = new Embeddings({
|
||||
embedApiClient: base.embedApiClient,
|
||||
dimensions: base.dimensions
|
||||
embedApiClient,
|
||||
dimensions
|
||||
})
|
||||
try {
|
||||
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, base.id) })
|
||||
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
|
||||
// Save database instance for later closing
|
||||
this.dbInstances.set(base.id, libSqlDb)
|
||||
this.dbInstances.set(id, libSqlDb)
|
||||
|
||||
ragApplication = await new RAGApplicationBuilder()
|
||||
.setModel('NO_MODEL')
|
||||
.setEmbeddingModel(embeddings)
|
||||
.setVectorDatabase(libSqlDb)
|
||||
.setSearchResultCount(base.documentCount || 30)
|
||||
.setSearchResultCount(documentCount || 30)
|
||||
.build()
|
||||
this.ragApplications.set(base.id, ragApplication)
|
||||
this.ragApplications.set(id, ragApplication)
|
||||
} catch (e) {
|
||||
logger.error('Failed to create RAGApplication:', e as Error)
|
||||
throw new Error(`Failed to create RAGApplication: ${e}`)
|
||||
@@ -189,14 +263,17 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
|
||||
return ragApplication
|
||||
}
|
||||
async initialize(base: KnowledgeBaseParams): Promise<void> {
|
||||
|
||||
public create = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
|
||||
await this.getRagApplication(base)
|
||||
}
|
||||
async reset(base: KnowledgeBaseParams): Promise<void> {
|
||||
const ragApp = await this.getRagApplication(base)
|
||||
await ragApp.reset()
|
||||
|
||||
public reset = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
|
||||
const ragApplication = await this.getRagApplication(base)
|
||||
await ragApplication.reset()
|
||||
}
|
||||
async delete(id: string): Promise<void> {
|
||||
|
||||
public async delete(_: Electron.IpcMainInvokeEvent, id: string): Promise<void> {
|
||||
logger.debug(`delete id: ${id}`)
|
||||
|
||||
await this.cleanupKnowledgeResources(id)
|
||||
@@ -209,41 +286,15 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
this.pendingDeleteManager.add(id)
|
||||
}
|
||||
}
|
||||
getLoaderTask(options: KnowledgeBaseAddItemOptionsNonNullableAttribute): LoaderTask {
|
||||
const { item } = options
|
||||
const getRagApplication = () => this.getRagApplication(options.base)
|
||||
switch (item.type) {
|
||||
case 'file':
|
||||
return this.fileTask(getRagApplication, options)
|
||||
case 'directory':
|
||||
return this.directoryTask(getRagApplication, options)
|
||||
case 'url':
|
||||
return this.urlTask(getRagApplication, options)
|
||||
case 'sitemap':
|
||||
return this.sitemapTask(getRagApplication, options)
|
||||
case 'note':
|
||||
return this.noteTask(getRagApplication, options)
|
||||
default:
|
||||
return {
|
||||
loaderTasks: [],
|
||||
loaderDoneReturn: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async remove(options: { uniqueIds: string[]; base: KnowledgeBaseParams }): Promise<void> {
|
||||
const ragApp = await this.getRagApplication(options.base)
|
||||
for (const id of options.uniqueIds) {
|
||||
await ragApp.deleteLoader(id)
|
||||
}
|
||||
private maximumLoad() {
|
||||
return (
|
||||
this.processingItemCount >= KnowledgeService.MAXIMUM_PROCESSING_ITEM_COUNT ||
|
||||
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
|
||||
)
|
||||
}
|
||||
async search(options: { search: string; base: KnowledgeBaseParams }): Promise<KnowledgeSearchResult[]> {
|
||||
const ragApp = await this.getRagApplication(options.base)
|
||||
return await ragApp.search(options.search)
|
||||
}
|
||||
|
||||
private fileTask(
|
||||
getRagApplication: () => Promise<RAGApplication>,
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload, userId } = options
|
||||
@@ -256,8 +307,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
task: async () => {
|
||||
try {
|
||||
// Add preprocessing logic
|
||||
const ragApplication = await getRagApplication()
|
||||
const fileToProcess: FileMetadata = await preprocessingService.preprocessFile(file, base, item, userId)
|
||||
const fileToProcess: FileMetadata = await this.preprocessing(file, base, item, userId)
|
||||
|
||||
// Use processed file for loading
|
||||
return addFileLoader(ragApplication, fileToProcess, base, forceReload)
|
||||
@@ -268,7 +318,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
.catch((e) => {
|
||||
logger.error(`Error in addFileLoader for ${file.name}: ${e}`)
|
||||
const errorResult: LoaderReturn = {
|
||||
...EmbedJsFramework.ERROR_LOADER_RETURN,
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: e.message,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
@@ -278,7 +328,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
} catch (e: any) {
|
||||
logger.error(`Preprocessing failed for ${file.name}: ${e}`)
|
||||
const errorResult: LoaderReturn = {
|
||||
...EmbedJsFramework.ERROR_LOADER_RETURN,
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: e.message,
|
||||
messageSource: 'preprocess'
|
||||
}
|
||||
@@ -295,7 +345,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
return loaderTask
|
||||
}
|
||||
private directoryTask(
|
||||
getRagApplication: () => Promise<RAGApplication>,
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
@@ -322,9 +372,8 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
for (const file of files) {
|
||||
loaderTasks.push({
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: async () => {
|
||||
const ragApplication = await getRagApplication()
|
||||
return addFileLoader(ragApplication, file, base, forceReload)
|
||||
task: () =>
|
||||
addFileLoader(ragApplication, file, base, forceReload)
|
||||
.then((result) => {
|
||||
loaderDoneReturn.entriesAdded += 1
|
||||
processedFiles += 1
|
||||
@@ -335,12 +384,11 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
.catch((err) => {
|
||||
logger.error('Failed to add dir loader:', err)
|
||||
return {
|
||||
...EmbedJsFramework.ERROR_LOADER_RETURN,
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add dir loader: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
})
|
||||
},
|
||||
}),
|
||||
evaluateTaskWorkload: { workload: file.size }
|
||||
})
|
||||
}
|
||||
@@ -352,7 +400,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
}
|
||||
|
||||
private urlTask(
|
||||
getRagApplication: () => Promise<RAGApplication>,
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
@@ -362,8 +410,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: async () => {
|
||||
const ragApplication = await getRagApplication()
|
||||
task: () => {
|
||||
const loaderReturn = ragApplication.addLoader(
|
||||
new WebLoader({
|
||||
urlOrContent: content,
|
||||
@@ -387,7 +434,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
.catch((err) => {
|
||||
logger.error('Failed to add url loader:', err)
|
||||
return {
|
||||
...EmbedJsFramework.ERROR_LOADER_RETURN,
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add url loader: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
@@ -402,7 +449,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
}
|
||||
|
||||
private sitemapTask(
|
||||
getRagApplication: () => Promise<RAGApplication>,
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
@@ -412,9 +459,8 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: async () => {
|
||||
const ragApplication = await getRagApplication()
|
||||
return ragApplication
|
||||
task: () =>
|
||||
ragApplication
|
||||
.addLoader(
|
||||
new SitemapLoader({ url: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }) as any,
|
||||
forceReload
|
||||
@@ -432,12 +478,11 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
.catch((err) => {
|
||||
logger.error('Failed to add sitemap loader:', err)
|
||||
return {
|
||||
...EmbedJsFramework.ERROR_LOADER_RETURN,
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add sitemap loader: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
})
|
||||
},
|
||||
}),
|
||||
evaluateTaskWorkload: { workload: 20 * MB }
|
||||
}
|
||||
],
|
||||
@@ -447,7 +492,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
}
|
||||
|
||||
private noteTask(
|
||||
getRagApplication: () => Promise<RAGApplication>,
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
@@ -460,8 +505,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: async () => {
|
||||
const ragApplication = await getRagApplication()
|
||||
task: () => {
|
||||
const loaderReturn = ragApplication.addLoader(
|
||||
new NoteLoader({
|
||||
text: content,
|
||||
@@ -484,7 +528,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
.catch((err) => {
|
||||
logger.error('Failed to add note loader:', err)
|
||||
return {
|
||||
...EmbedJsFramework.ERROR_LOADER_RETURN,
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add note loader: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
@@ -497,4 +541,199 @@ export class EmbedJsFramework implements IKnowledgeFramework {
|
||||
}
|
||||
return loaderTask
|
||||
}
|
||||
|
||||
private processingQueueHandle() {
|
||||
const getSubtasksUntilMaximumLoad = (): QueueTaskItem[] => {
|
||||
const queueTaskList: QueueTaskItem[] = []
|
||||
that: for (const [task, resolve] of this.knowledgeItemProcessingQueueMappingPromise) {
|
||||
for (const item of task.loaderTasks) {
|
||||
if (this.maximumLoad()) {
|
||||
break that
|
||||
}
|
||||
|
||||
const { state, task: taskPromise, evaluateTaskWorkload } = item
|
||||
|
||||
if (state !== LoaderTaskItemState.PENDING) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { workload } = evaluateTaskWorkload
|
||||
this.workload += workload
|
||||
this.processingItemCount += 1
|
||||
item.state = LoaderTaskItemState.PROCESSING
|
||||
queueTaskList.push({
|
||||
taskPromise: () =>
|
||||
taskPromise().then(() => {
|
||||
this.workload -= workload
|
||||
this.processingItemCount -= 1
|
||||
task.loaderTasks.delete(item)
|
||||
if (task.loaderTasks.size === 0) {
|
||||
this.knowledgeItemProcessingQueueMappingPromise.delete(task)
|
||||
resolve()
|
||||
}
|
||||
this.processingQueueHandle()
|
||||
}),
|
||||
resolve: () => {},
|
||||
evaluateTaskWorkload
|
||||
})
|
||||
}
|
||||
}
|
||||
return queueTaskList
|
||||
}
|
||||
const subTasks = getSubtasksUntilMaximumLoad()
|
||||
if (subTasks.length > 0) {
|
||||
const subTaskPromises = subTasks.map(({ taskPromise }) => taskPromise())
|
||||
Promise.all(subTaskPromises).then(() => {
|
||||
subTasks.forEach(({ resolve }) => resolve())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private appendProcessingQueue(task: LoaderTask): Promise<LoaderReturn> {
|
||||
return new Promise((resolve) => {
|
||||
this.knowledgeItemProcessingQueueMappingPromise.set(loaderTaskIntoOfSet(task), () => {
|
||||
resolve(task.loaderDoneReturn!)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
|
||||
return new Promise((resolve) => {
|
||||
const { base, item, forceReload = false, userId = '' } = options
|
||||
const optionsNonNullableAttribute = { base, item, forceReload, userId }
|
||||
this.getRagApplication(base)
|
||||
.then((ragApplication) => {
|
||||
const task = (() => {
|
||||
switch (item.type) {
|
||||
case 'file':
|
||||
return this.fileTask(ragApplication, optionsNonNullableAttribute)
|
||||
case 'directory':
|
||||
return this.directoryTask(ragApplication, optionsNonNullableAttribute)
|
||||
case 'url':
|
||||
return this.urlTask(ragApplication, optionsNonNullableAttribute)
|
||||
case 'sitemap':
|
||||
return this.sitemapTask(ragApplication, optionsNonNullableAttribute)
|
||||
case 'note':
|
||||
return this.noteTask(ragApplication, optionsNonNullableAttribute)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
if (task) {
|
||||
this.appendProcessingQueue(task).then(() => {
|
||||
resolve(task.loaderDoneReturn!)
|
||||
})
|
||||
this.processingQueueHandle()
|
||||
} else {
|
||||
resolve({
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: 'Unsupported item type',
|
||||
messageSource: 'embedding'
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Failed to add item:', err)
|
||||
resolve({
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add item: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@TraceMethod({ spanName: 'remove', tag: 'Knowledge' })
|
||||
public async remove(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }
|
||||
): Promise<void> {
|
||||
const ragApplication = await this.getRagApplication(base)
|
||||
logger.debug(`Remove Item UniqueId: ${uniqueId}`)
|
||||
for (const id of uniqueIds) {
|
||||
await ragApplication.deleteLoader(id)
|
||||
}
|
||||
}
|
||||
|
||||
@TraceMethod({ spanName: 'RagSearch', tag: 'Knowledge' })
|
||||
public async search(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ search, base }: { search: string; base: KnowledgeBaseParams }
|
||||
): Promise<ExtractChunkData[]> {
|
||||
const ragApplication = await this.getRagApplication(base)
|
||||
return await ragApplication.search(search)
|
||||
}
|
||||
|
||||
@TraceMethod({ spanName: 'rerank', tag: 'Knowledge' })
|
||||
public async rerank(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }
|
||||
): Promise<ExtractChunkData[]> {
|
||||
if (results.length === 0) {
|
||||
return results
|
||||
}
|
||||
return await new Reranker(base).rerank(search, results)
|
||||
}
|
||||
|
||||
public getStorageDir = (): string => {
|
||||
return this.storageDir
|
||||
}
|
||||
|
||||
private preprocessing = async (
|
||||
file: FileMetadata,
|
||||
base: KnowledgeBaseParams,
|
||||
item: KnowledgeItem,
|
||||
userId: string
|
||||
): Promise<FileMetadata> => {
|
||||
let fileToProcess: FileMetadata = file
|
||||
if (base.preprocessProvider && file.ext.toLowerCase() === '.pdf') {
|
||||
try {
|
||||
const provider = new PreprocessProvider(base.preprocessProvider.provider, userId)
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
// Check if file has already been preprocessed
|
||||
const alreadyProcessed = await provider.checkIfAlreadyProcessed(file)
|
||||
if (alreadyProcessed) {
|
||||
logger.debug(`File already preprocess processed, using cached result: ${filePath}`)
|
||||
return alreadyProcessed
|
||||
}
|
||||
|
||||
// Execute preprocessing
|
||||
logger.debug(`Starting preprocess processing for scanned PDF: ${filePath}`)
|
||||
const { processedFile, quota } = await provider.parseFile(item.id, file)
|
||||
fileToProcess = processedFile
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
mainWindow?.webContents.send('file-preprocess-finished', {
|
||||
itemId: item.id,
|
||||
quota: quota
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error(`Preprocess processing failed: ${err}`)
|
||||
// If preprocessing fails, use original file
|
||||
// fileToProcess = file
|
||||
throw new Error(`Preprocess processing failed: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
return fileToProcess
|
||||
}
|
||||
|
||||
public checkQuota = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
base: KnowledgeBaseParams,
|
||||
userId: string
|
||||
): Promise<number> => {
|
||||
try {
|
||||
if (base.preprocessProvider && base.preprocessProvider.type === 'preprocess') {
|
||||
const provider = new PreprocessProvider(base.preprocessProvider.provider, userId)
|
||||
return await provider.checkQuota()
|
||||
}
|
||||
throw new Error('No preprocess provider configured')
|
||||
} catch (err) {
|
||||
logger.error(`Failed to check quota: ${err}`)
|
||||
throw new Error(`Failed to check quota: ${err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new KnowledgeService()
|
||||
@@ -56,45 +56,6 @@ type CallToolArgs = { server: MCPServer; name: string; args: any; callId?: strin
|
||||
|
||||
const logger = loggerService.withContext('MCPService')
|
||||
|
||||
// Redact potentially sensitive fields in objects (headers, tokens, api keys)
|
||||
function redactSensitive(input: any): any {
|
||||
const SENSITIVE_KEYS = ['authorization', 'Authorization', 'apiKey', 'api_key', 'apikey', 'token', 'access_token']
|
||||
const MAX_STRING = 300
|
||||
|
||||
const redact = (val: any): any => {
|
||||
if (val == null) return val
|
||||
if (typeof val === 'string') {
|
||||
return val.length > MAX_STRING ? `${val.slice(0, MAX_STRING)}…<${val.length - MAX_STRING} more>` : val
|
||||
}
|
||||
if (Array.isArray(val)) return val.map((v) => redact(v))
|
||||
if (typeof val === 'object') {
|
||||
const out: Record<string, any> = {}
|
||||
for (const [k, v] of Object.entries(val)) {
|
||||
if (SENSITIVE_KEYS.includes(k)) {
|
||||
out[k] = '<redacted>'
|
||||
} else {
|
||||
out[k] = redact(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
return redact(input)
|
||||
}
|
||||
|
||||
// Create a context-aware logger for a server
|
||||
function getServerLogger(server: MCPServer, extra?: Record<string, any>) {
|
||||
const base = {
|
||||
serverName: server?.name,
|
||||
serverId: server?.id,
|
||||
baseUrl: server?.baseUrl,
|
||||
type: server?.type || (server?.command ? 'stdio' : server?.baseUrl ? 'http' : 'inmemory')
|
||||
}
|
||||
return loggerService.withContext('MCPService', { ...base, ...(extra || {}) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order function to add caching capability to any async function
|
||||
* @param fn The original function to be wrapped with caching
|
||||
@@ -113,17 +74,15 @@ function withCache<T extends unknown[], R>(
|
||||
const cacheKey = getCacheKey(...args)
|
||||
|
||||
if (CacheService.has(cacheKey)) {
|
||||
logger.debug(`${logPrefix} loaded from cache`, { cacheKey })
|
||||
logger.debug(`${logPrefix} loaded from cache`)
|
||||
const cachedData = CacheService.get<R>(cacheKey)
|
||||
if (cachedData) {
|
||||
return cachedData
|
||||
}
|
||||
}
|
||||
|
||||
const start = Date.now()
|
||||
const result = await fn(...args)
|
||||
CacheService.set(cacheKey, result, ttl)
|
||||
logger.debug(`${logPrefix} cached`, { cacheKey, ttlMs: ttl, durationMs: Date.now() - start })
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -169,7 +128,6 @@ class McpService {
|
||||
// If there's a pending initialization, wait for it
|
||||
const pendingClient = this.pendingClients.get(serverKey)
|
||||
if (pendingClient) {
|
||||
getServerLogger(server).silly(`Waiting for pending client initialization`)
|
||||
return pendingClient
|
||||
}
|
||||
|
||||
@@ -178,11 +136,8 @@ class McpService {
|
||||
if (existingClient) {
|
||||
try {
|
||||
// Check if the existing client is still connected
|
||||
const pingResult = await existingClient.ping({
|
||||
// add short timeout to prevent hanging
|
||||
timeout: 1000
|
||||
})
|
||||
getServerLogger(server).debug(`Ping result`, { ok: !!pingResult })
|
||||
const pingResult = await existingClient.ping()
|
||||
logger.debug(`Ping result for ${server.name}:`, pingResult)
|
||||
// If the ping fails, remove the client from the cache
|
||||
// and create a new one
|
||||
if (!pingResult) {
|
||||
@@ -191,7 +146,7 @@ class McpService {
|
||||
return existingClient
|
||||
}
|
||||
} catch (error: any) {
|
||||
getServerLogger(server).error(`Error pinging server`, error as Error)
|
||||
logger.error(`Error pinging server ${server.name}:`, error?.message)
|
||||
this.clients.delete(serverKey)
|
||||
}
|
||||
}
|
||||
@@ -217,15 +172,15 @@ class McpService {
|
||||
> => {
|
||||
// Create appropriate transport based on configuration
|
||||
if (isBuiltinMCPServer(server) && server.name !== BuiltinMCPServerNames.mcpAutoInstall) {
|
||||
getServerLogger(server).debug(`Using in-memory transport`)
|
||||
logger.debug(`Using in-memory transport for server: ${server.name}`)
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
// start the in-memory server with the given name and environment variables
|
||||
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
|
||||
try {
|
||||
await inMemoryServer.connect(serverTransport)
|
||||
getServerLogger(server).debug(`In-memory server started`)
|
||||
logger.debug(`In-memory server started: ${server.name}`)
|
||||
} catch (error: Error | any) {
|
||||
getServerLogger(server).error(`Error starting in-memory server`, error as Error)
|
||||
logger.error(`Error starting in-memory server: ${error}`)
|
||||
throw new Error(`Failed to start in-memory server: ${error.message}`)
|
||||
}
|
||||
// set the client transport to the client
|
||||
@@ -238,10 +193,7 @@ class McpService {
|
||||
},
|
||||
authProvider
|
||||
}
|
||||
// redact headers before logging
|
||||
getServerLogger(server).debug(`StreamableHTTPClientTransport options`, {
|
||||
options: redactSensitive(options)
|
||||
})
|
||||
logger.debug(`StreamableHTTPClientTransport options:`, options)
|
||||
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
|
||||
} else if (server.type === 'sse') {
|
||||
const options: SSEClientTransportOptions = {
|
||||
@@ -257,7 +209,7 @@ class McpService {
|
||||
headers['Authorization'] = `Bearer ${tokens.access_token}`
|
||||
}
|
||||
} catch (error) {
|
||||
getServerLogger(server).error('Failed to fetch tokens:', error as Error)
|
||||
logger.error('Failed to fetch tokens:', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,18 +239,15 @@ class McpService {
|
||||
...server.env,
|
||||
...resolvedConfig.env
|
||||
}
|
||||
getServerLogger(server).debug(`Using resolved DXT config`, {
|
||||
command: cmd,
|
||||
args
|
||||
})
|
||||
logger.debug(`Using resolved DXT config - command: ${cmd}, args: ${args?.join(' ')}`)
|
||||
} else {
|
||||
getServerLogger(server).warn(`Failed to resolve DXT config, falling back to manifest values`)
|
||||
logger.warn(`Failed to resolve DXT config for ${server.name}, falling back to manifest values`)
|
||||
}
|
||||
}
|
||||
|
||||
if (server.command === 'npx') {
|
||||
cmd = await getBinaryPath('bun')
|
||||
getServerLogger(server).debug(`Using command`, { command: cmd })
|
||||
logger.debug(`Using command: ${cmd}`)
|
||||
|
||||
// add -x to args if args exist
|
||||
if (args && args.length > 0) {
|
||||
@@ -333,7 +282,7 @@ class McpService {
|
||||
}
|
||||
}
|
||||
|
||||
getServerLogger(server).debug(`Starting server`, { command: cmd, args })
|
||||
logger.debug(`Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
|
||||
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
||||
const loginShellEnv = await this.getLoginShellEnv()
|
||||
|
||||
@@ -355,14 +304,12 @@ class McpService {
|
||||
// For DXT servers, set the working directory to the extracted path
|
||||
if (server.dxtPath) {
|
||||
transportOptions.cwd = server.dxtPath
|
||||
getServerLogger(server).debug(`Setting working directory for DXT server`, {
|
||||
cwd: server.dxtPath
|
||||
})
|
||||
logger.debug(`Setting working directory for DXT server: ${server.dxtPath}`)
|
||||
}
|
||||
|
||||
const stdioTransport = new StdioClientTransport(transportOptions)
|
||||
stdioTransport.stderr?.on('data', (data) =>
|
||||
getServerLogger(server).debug(`Stdio stderr`, { data: data.toString() })
|
||||
logger.debug(`Stdio stderr for server: ${server.name}` + data.toString())
|
||||
)
|
||||
return stdioTransport
|
||||
} else {
|
||||
@@ -371,7 +318,7 @@ class McpService {
|
||||
}
|
||||
|
||||
const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
|
||||
getServerLogger(server).debug(`Starting OAuth flow`)
|
||||
logger.debug(`Starting OAuth flow for server: ${server.name}`)
|
||||
// Create an event emitter for the OAuth callback
|
||||
const events = new EventEmitter()
|
||||
|
||||
@@ -384,27 +331,27 @@ class McpService {
|
||||
|
||||
// Set a timeout to close the callback server
|
||||
const timeoutId = setTimeout(() => {
|
||||
getServerLogger(server).warn(`OAuth flow timed out`)
|
||||
logger.warn(`OAuth flow timed out for server: ${server.name}`)
|
||||
callbackServer.close()
|
||||
}, 300000) // 5 minutes timeout
|
||||
|
||||
try {
|
||||
// Wait for the authorization code
|
||||
const authCode = await callbackServer.waitForAuthCode()
|
||||
getServerLogger(server).debug(`Received auth code`)
|
||||
logger.debug(`Received auth code: ${authCode}`)
|
||||
|
||||
// Complete the OAuth flow
|
||||
await transport.finishAuth(authCode)
|
||||
|
||||
getServerLogger(server).debug(`OAuth flow completed`)
|
||||
logger.debug(`OAuth flow completed for server: ${server.name}`)
|
||||
|
||||
const newTransport = await initTransport()
|
||||
// Try to connect again
|
||||
await client.connect(newTransport)
|
||||
|
||||
getServerLogger(server).debug(`Successfully authenticated`)
|
||||
logger.debug(`Successfully authenticated with server: ${server.name}`)
|
||||
} catch (oauthError) {
|
||||
getServerLogger(server).error(`OAuth authentication failed`, oauthError as Error)
|
||||
logger.error(`OAuth authentication failed for server ${server.name}:`, oauthError as Error)
|
||||
throw new Error(
|
||||
`OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
|
||||
)
|
||||
@@ -443,7 +390,7 @@ class McpService {
|
||||
logger.debug(`Activated server: ${server.name}`)
|
||||
return client
|
||||
} catch (error: any) {
|
||||
getServerLogger(server).error(`Error activating server`, error as Error)
|
||||
logger.error(`Error activating server ${server.name}:`, error?.message)
|
||||
throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
|
||||
}
|
||||
} finally {
|
||||
@@ -503,9 +450,9 @@ class McpService {
|
||||
logger.debug(`Message from server ${server.name}:`, notification.params)
|
||||
})
|
||||
|
||||
getServerLogger(server).debug(`Set up notification handlers`)
|
||||
logger.debug(`Set up notification handlers for server: ${server.name}`)
|
||||
} catch (error) {
|
||||
getServerLogger(server).error(`Failed to set up notification handlers`, error as Error)
|
||||
logger.error(`Failed to set up notification handlers for server ${server.name}:`, error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,7 +470,7 @@ class McpService {
|
||||
CacheService.remove(`mcp:list_tool:${serverKey}`)
|
||||
CacheService.remove(`mcp:list_prompts:${serverKey}`)
|
||||
CacheService.remove(`mcp:list_resources:${serverKey}`)
|
||||
logger.debug(`Cleared all caches for server`, { serverKey })
|
||||
logger.debug(`Cleared all caches for server: ${serverKey}`)
|
||||
}
|
||||
|
||||
async closeClient(serverKey: string) {
|
||||
@@ -531,18 +478,18 @@ class McpService {
|
||||
if (client) {
|
||||
// Remove the client from the cache
|
||||
await client.close()
|
||||
logger.debug(`Closed server`, { serverKey })
|
||||
logger.debug(`Closed server: ${serverKey}`)
|
||||
this.clients.delete(serverKey)
|
||||
// Clear all caches for this server
|
||||
this.clearServerCache(serverKey)
|
||||
} else {
|
||||
logger.warn(`No client found for server`, { serverKey })
|
||||
logger.warn(`No client found for server: ${serverKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
async stopServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||
const serverKey = this.getServerKey(server)
|
||||
getServerLogger(server).debug(`Stopping server`)
|
||||
logger.debug(`Stopping server: ${server.name}`)
|
||||
await this.closeClient(serverKey)
|
||||
}
|
||||
|
||||
@@ -558,16 +505,16 @@ class McpService {
|
||||
try {
|
||||
const cleaned = this.dxtService.cleanupDxtServer(server.name)
|
||||
if (cleaned) {
|
||||
getServerLogger(server).debug(`Cleaned up DXT server directory`)
|
||||
logger.debug(`Cleaned up DXT server directory for: ${server.name}`)
|
||||
}
|
||||
} catch (error) {
|
||||
getServerLogger(server).error(`Failed to cleanup DXT server`, error as Error)
|
||||
logger.error(`Failed to cleanup DXT server: ${server.name}`, error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async restartServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||
getServerLogger(server).debug(`Restarting server`)
|
||||
logger.debug(`Restarting server: ${server.name}`)
|
||||
const serverKey = this.getServerKey(server)
|
||||
await this.closeClient(serverKey)
|
||||
// Clear cache before restarting to ensure fresh data
|
||||
@@ -580,7 +527,7 @@ class McpService {
|
||||
try {
|
||||
await this.closeClient(key)
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to close client`, error as Error)
|
||||
logger.error(`Failed to close client: ${error?.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -589,9 +536,9 @@ class McpService {
|
||||
* Check connectivity for an MCP server
|
||||
*/
|
||||
public async checkMcpConnectivity(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<boolean> {
|
||||
getServerLogger(server).debug(`Checking connectivity`)
|
||||
logger.debug(`Checking connectivity for server: ${server.name}`)
|
||||
try {
|
||||
getServerLogger(server).debug(`About to call initClient`, { hasInitClient: !!this.initClient })
|
||||
logger.debug(`About to call initClient for server: ${server.name}`, { hasInitClient: !!this.initClient })
|
||||
|
||||
if (!this.initClient) {
|
||||
throw new Error('initClient method is not available')
|
||||
@@ -600,10 +547,10 @@ class McpService {
|
||||
const client = await this.initClient(server)
|
||||
// Attempt to list tools as a way to check connectivity
|
||||
await client.listTools()
|
||||
getServerLogger(server).debug(`Connectivity check successful`)
|
||||
logger.debug(`Connectivity check successful for server: ${server.name}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
getServerLogger(server).error(`Connectivity check failed`, error as Error)
|
||||
logger.error(`Connectivity check failed for server: ${server.name}`, error as Error)
|
||||
// Close the client if connectivity check fails to ensure a clean state for the next attempt
|
||||
const serverKey = this.getServerKey(server)
|
||||
await this.closeClient(serverKey)
|
||||
@@ -612,8 +559,9 @@ class McpService {
|
||||
}
|
||||
|
||||
private async listToolsImpl(server: MCPServer): Promise<MCPTool[]> {
|
||||
getServerLogger(server).debug(`Listing tools`)
|
||||
logger.debug(`Listing tools for server: ${server.name}`)
|
||||
const client = await this.initClient(server)
|
||||
logger.debug(`Client for server: ${server.name}`, client)
|
||||
try {
|
||||
const { tools } = await client.listTools()
|
||||
const serverTools: MCPTool[] = []
|
||||
@@ -629,7 +577,7 @@ class McpService {
|
||||
})
|
||||
return serverTools
|
||||
} catch (error: any) {
|
||||
getServerLogger(server).error(`Failed to list tools`, error as Error)
|
||||
logger.error(`Failed to list tools for server: ${server.name}`, error?.message)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -666,16 +614,12 @@ class McpService {
|
||||
|
||||
const callToolFunc = async ({ server, name, args }: CallToolArgs) => {
|
||||
try {
|
||||
getServerLogger(server, { tool: name, callId: toolCallId }).debug(`Calling tool`, {
|
||||
args: redactSensitive(args)
|
||||
})
|
||||
logger.debug(`Calling: ${server.name} ${name} ${JSON.stringify(args)} callId: ${toolCallId}`, server)
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args)
|
||||
} catch (e) {
|
||||
getServerLogger(server, { tool: name, callId: toolCallId }).error('args parse error', e as Error, {
|
||||
args
|
||||
})
|
||||
logger.error('args parse error', args)
|
||||
}
|
||||
if (args === '') {
|
||||
args = {}
|
||||
@@ -684,9 +628,8 @@ class McpService {
|
||||
const client = await this.initClient(server)
|
||||
const result = await client.callTool({ name, arguments: args }, undefined, {
|
||||
onprogress: (process) => {
|
||||
getServerLogger(server, { tool: name, callId: toolCallId }).debug(`Progress`, {
|
||||
ratio: process.progress / (process.total || 1)
|
||||
})
|
||||
logger.debug(`Progress: ${process.progress / (process.total || 1)}`)
|
||||
logger.debug(`Progress notification received for server: ${server.name}`, process)
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('mcp-progress', process.progress / (process.total || 1))
|
||||
@@ -701,7 +644,7 @@ class McpService {
|
||||
})
|
||||
return result as MCPCallToolResponse
|
||||
} catch (error) {
|
||||
getServerLogger(server, { tool: name, callId: toolCallId }).error(`Error calling tool`, error as Error)
|
||||
logger.error(`Error calling tool ${name} on ${server.name}:`, error as Error)
|
||||
throw error
|
||||
} finally {
|
||||
this.activeToolCalls.delete(toolCallId)
|
||||
@@ -725,7 +668,7 @@ class McpService {
|
||||
*/
|
||||
private async listPromptsImpl(server: MCPServer): Promise<MCPPrompt[]> {
|
||||
const client = await this.initClient(server)
|
||||
getServerLogger(server).debug(`Listing prompts`)
|
||||
logger.debug(`Listing prompts for server: ${server.name}`)
|
||||
try {
|
||||
const { prompts } = await client.listPrompts()
|
||||
return prompts.map((prompt: any) => ({
|
||||
@@ -737,7 +680,7 @@ class McpService {
|
||||
} catch (error: any) {
|
||||
// -32601 is the code for the method not found
|
||||
if (error?.code !== -32601) {
|
||||
getServerLogger(server).error(`Failed to list prompts`, error as Error)
|
||||
logger.error(`Failed to list prompts for server: ${server.name}`, error?.message)
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -806,7 +749,7 @@ class McpService {
|
||||
} catch (error: any) {
|
||||
// -32601 is the code for the method not found
|
||||
if (error?.code !== -32601) {
|
||||
getServerLogger(server).error(`Failed to list resources`, error as Error)
|
||||
logger.error(`Failed to list resources for server: ${server.name}`, error?.message)
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -832,7 +775,7 @@ class McpService {
|
||||
* Get a specific resource from an MCP server (implementation)
|
||||
*/
|
||||
private async getResourceImpl(server: MCPServer, uri: string): Promise<GetResourceResponse> {
|
||||
getServerLogger(server, { uri }).debug(`Getting resource`)
|
||||
logger.debug(`Getting resource ${uri} from server: ${server.name}`)
|
||||
const client = await this.initClient(server)
|
||||
try {
|
||||
const result = await client.readResource({ uri: uri })
|
||||
@@ -850,7 +793,7 @@ class McpService {
|
||||
contents: contents
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
getServerLogger(server, { uri }).error(`Failed to get resource`, error as Error)
|
||||
logger.error(`Failed to get resource ${uri} from server: ${server.name}`, error.message)
|
||||
throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
@@ -895,10 +838,10 @@ class McpService {
|
||||
if (activeToolCall) {
|
||||
activeToolCall.abort()
|
||||
this.activeToolCalls.delete(callId)
|
||||
logger.debug(`Aborted tool call`, { callId })
|
||||
logger.debug(`Aborted tool call: ${callId}`)
|
||||
return true
|
||||
} else {
|
||||
logger.warn(`No active tool call found for callId`, { callId })
|
||||
logger.warn(`No active tool call found for callId: ${callId}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -908,22 +851,22 @@ class McpService {
|
||||
*/
|
||||
public async getServerVersion(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<string | null> {
|
||||
try {
|
||||
getServerLogger(server).debug(`Getting server version`)
|
||||
logger.debug(`Getting server version for: ${server.name}`)
|
||||
const client = await this.initClient(server)
|
||||
|
||||
// Try to get server information which may include version
|
||||
const serverInfo = client.getServerVersion()
|
||||
getServerLogger(server).debug(`Server info`, redactSensitive(serverInfo))
|
||||
logger.debug(`Server info for ${server.name}:`, serverInfo)
|
||||
|
||||
if (serverInfo && serverInfo.version) {
|
||||
getServerLogger(server).debug(`Server version`, { version: serverInfo.version })
|
||||
logger.debug(`Server version for ${server.name}: ${serverInfo.version}`)
|
||||
return serverInfo.version
|
||||
}
|
||||
|
||||
getServerLogger(server).warn(`No version information available`)
|
||||
logger.warn(`No version information available for server: ${server.name}`)
|
||||
return null
|
||||
} catch (error: any) {
|
||||
getServerLogger(server).error(`Failed to get server version`, error as Error)
|
||||
logger.error(`Failed to get server version for ${server.name}:`, error?.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function initSessionUserAgent() {
|
||||
wvSession.webRequest.onBeforeSendHeaders((details, cb) => {
|
||||
const headers = {
|
||||
...details.requestHeaders,
|
||||
'User-Agent': details.url.includes('google.com') ? originUA : newUA
|
||||
'User-Agent': newUA
|
||||
}
|
||||
cb({ requestHeaders: headers })
|
||||
})
|
||||
|
||||
@@ -66,19 +66,11 @@ export class WindowService {
|
||||
transparent: false,
|
||||
vibrancy: 'sidebar',
|
||||
visualEffectState: 'active',
|
||||
// For Windows and Linux, we use frameless window with custom controls
|
||||
// For Mac, we keep the native title bar style
|
||||
...(isMac
|
||||
? {
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
trafficLightPosition: { x: 8, y: 13 }
|
||||
}
|
||||
: {
|
||||
frame: false // Frameless window for Windows and Linux
|
||||
}),
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
|
||||
darkTheme: nativeTheme.shouldUseDarkColors,
|
||||
trafficLightPosition: { x: 8, y: 13 },
|
||||
...(isLinux ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { LoaderReturn } from '@shared/config/types'
|
||||
import { KnowledgeBaseParams, KnowledgeItem, KnowledgeSearchResult } from '@types'
|
||||
|
||||
export interface KnowledgeBaseAddItemOptions {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload?: boolean
|
||||
userId?: string
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseAddItemOptionsNonNullableAttribute {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload: boolean
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface EvaluateTaskWorkload {
|
||||
workload: number
|
||||
}
|
||||
|
||||
export type LoaderDoneReturn = LoaderReturn | null
|
||||
|
||||
export enum LoaderTaskItemState {
|
||||
PENDING,
|
||||
PROCESSING,
|
||||
DONE
|
||||
}
|
||||
|
||||
export interface LoaderTaskItem {
|
||||
state: LoaderTaskItemState
|
||||
task: () => Promise<unknown>
|
||||
evaluateTaskWorkload: EvaluateTaskWorkload
|
||||
}
|
||||
|
||||
export interface LoaderTask {
|
||||
loaderTasks: LoaderTaskItem[]
|
||||
loaderDoneReturn: LoaderDoneReturn
|
||||
}
|
||||
|
||||
export interface LoaderTaskOfSet {
|
||||
loaderTasks: Set<LoaderTaskItem>
|
||||
loaderDoneReturn: LoaderDoneReturn
|
||||
}
|
||||
|
||||
export interface QueueTaskItem {
|
||||
taskPromise: () => Promise<unknown>
|
||||
resolve: () => void
|
||||
evaluateTaskWorkload: EvaluateTaskWorkload
|
||||
}
|
||||
|
||||
export const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => {
|
||||
return {
|
||||
loaderTasks: new Set(loaderTask.loaderTasks),
|
||||
loaderDoneReturn: loaderTask.loaderDoneReturn
|
||||
}
|
||||
}
|
||||
|
||||
export interface IKnowledgeFramework {
|
||||
/** 为给定知识库初始化框架资源 */
|
||||
initialize(base: KnowledgeBaseParams): Promise<void>
|
||||
/** 重置知识库,删除其所有内容 */
|
||||
reset(base: KnowledgeBaseParams): Promise<void>
|
||||
/** 删除与知识库关联的资源,包括文件 */
|
||||
delete(id: string): Promise<void>
|
||||
/** 生成用于添加条目的任务对象,由队列处理 */
|
||||
getLoaderTask(options: KnowledgeBaseAddItemOptionsNonNullableAttribute): LoaderTask
|
||||
/** 从知识库中删除特定条目 */
|
||||
remove(options: { uniqueIds: string[]; base: KnowledgeBaseParams }): Promise<void>
|
||||
/** 搜索知识库 */
|
||||
search(options: { search: string; base: KnowledgeBaseParams }): Promise<KnowledgeSearchResult[]>
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
import { app } from 'electron'
|
||||
|
||||
import { EmbedJsFramework } from './EmbedJsFramework'
|
||||
import { IKnowledgeFramework } from './IKnowledgeFramework'
|
||||
import { LangChainFramework } from './LangChainFramework'
|
||||
class KnowledgeFrameworkFactory {
|
||||
private static instance: KnowledgeFrameworkFactory
|
||||
private frameworks: Map<string, IKnowledgeFramework> = new Map()
|
||||
private storageDir: string
|
||||
|
||||
private constructor(storageDir: string) {
|
||||
this.storageDir = storageDir
|
||||
}
|
||||
|
||||
public static getInstance(storageDir: string): KnowledgeFrameworkFactory {
|
||||
if (!KnowledgeFrameworkFactory.instance) {
|
||||
KnowledgeFrameworkFactory.instance = new KnowledgeFrameworkFactory(storageDir)
|
||||
}
|
||||
return KnowledgeFrameworkFactory.instance
|
||||
}
|
||||
|
||||
public getFramework(base: KnowledgeBaseParams): IKnowledgeFramework {
|
||||
const frameworkType = base.framework || 'embedjs' // 如果未指定,默认为 embedjs
|
||||
if (this.frameworks.has(frameworkType)) {
|
||||
return this.frameworks.get(frameworkType)!
|
||||
}
|
||||
let framework: IKnowledgeFramework
|
||||
switch (frameworkType) {
|
||||
case 'langchain':
|
||||
framework = new LangChainFramework(this.storageDir)
|
||||
break
|
||||
case 'embedjs':
|
||||
default:
|
||||
framework = new EmbedJsFramework(this.storageDir)
|
||||
break
|
||||
}
|
||||
|
||||
this.frameworks.set(frameworkType, framework)
|
||||
return framework
|
||||
}
|
||||
}
|
||||
|
||||
export const knowledgeFrameworkFactory = KnowledgeFrameworkFactory.getInstance(
|
||||
path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
|
||||
)
|
||||
@@ -1,190 +0,0 @@
|
||||
import * as fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { preprocessingService } from '@main/knowledge/preprocess/PreprocessingService'
|
||||
import Reranker from '@main/knowledge/reranker/Reranker'
|
||||
import { TraceMethod } from '@mcp-trace/trace-core'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { LoaderReturn } from '@shared/config/types'
|
||||
import { KnowledgeBaseParams, KnowledgeSearchResult } from '@types'
|
||||
import { app } from 'electron'
|
||||
|
||||
import {
|
||||
KnowledgeBaseAddItemOptions,
|
||||
LoaderTask,
|
||||
loaderTaskIntoOfSet,
|
||||
LoaderTaskItemState,
|
||||
LoaderTaskOfSet,
|
||||
QueueTaskItem
|
||||
} from './IKnowledgeFramework'
|
||||
import { knowledgeFrameworkFactory } from './KnowledgeFrameworkFactory'
|
||||
|
||||
const logger = loggerService.withContext('MainKnowledgeService')
|
||||
|
||||
class KnowledgeService {
|
||||
private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
|
||||
|
||||
private workload = 0
|
||||
private processingItemCount = 0
|
||||
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
|
||||
private static MAXIMUM_WORKLOAD = 80 * MB
|
||||
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
|
||||
private static ERROR_LOADER_RETURN: LoaderReturn = {
|
||||
entriesAdded: 0,
|
||||
uniqueId: '',
|
||||
uniqueIds: [''],
|
||||
loaderType: '',
|
||||
status: 'failed'
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.initStorageDir()
|
||||
}
|
||||
|
||||
private initStorageDir = (): void => {
|
||||
if (!fs.existsSync(this.storageDir)) {
|
||||
fs.mkdirSync(this.storageDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private maximumLoad() {
|
||||
return (
|
||||
this.processingItemCount >= KnowledgeService.MAXIMUM_PROCESSING_ITEM_COUNT ||
|
||||
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
|
||||
)
|
||||
}
|
||||
|
||||
private processingQueueHandle() {
|
||||
const getSubtasksUntilMaximumLoad = (): QueueTaskItem[] => {
|
||||
const queueTaskList: QueueTaskItem[] = []
|
||||
that: for (const [task, resolve] of this.knowledgeItemProcessingQueueMappingPromise) {
|
||||
for (const item of task.loaderTasks) {
|
||||
if (this.maximumLoad()) {
|
||||
break that
|
||||
}
|
||||
|
||||
const { state, task: taskPromise, evaluateTaskWorkload } = item
|
||||
|
||||
if (state !== LoaderTaskItemState.PENDING) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { workload } = evaluateTaskWorkload
|
||||
this.workload += workload
|
||||
this.processingItemCount += 1
|
||||
item.state = LoaderTaskItemState.PROCESSING
|
||||
queueTaskList.push({
|
||||
taskPromise: () =>
|
||||
taskPromise().then(() => {
|
||||
this.workload -= workload
|
||||
this.processingItemCount -= 1
|
||||
task.loaderTasks.delete(item)
|
||||
if (task.loaderTasks.size === 0) {
|
||||
this.knowledgeItemProcessingQueueMappingPromise.delete(task)
|
||||
resolve()
|
||||
}
|
||||
this.processingQueueHandle()
|
||||
}),
|
||||
resolve: () => {},
|
||||
evaluateTaskWorkload
|
||||
})
|
||||
}
|
||||
}
|
||||
return queueTaskList
|
||||
}
|
||||
const subTasks = getSubtasksUntilMaximumLoad()
|
||||
if (subTasks.length > 0) {
|
||||
const subTaskPromises = subTasks.map(({ taskPromise }) => taskPromise())
|
||||
Promise.all(subTaskPromises).then(() => {
|
||||
subTasks.forEach(({ resolve }) => resolve())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private appendProcessingQueue(task: LoaderTask): Promise<LoaderReturn> {
|
||||
return new Promise((resolve) => {
|
||||
this.knowledgeItemProcessingQueueMappingPromise.set(loaderTaskIntoOfSet(task), () => {
|
||||
resolve(task.loaderDoneReturn!)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public async create(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> {
|
||||
logger.info(`Creating knowledge base: ${JSON.stringify(base)}`)
|
||||
const framework = knowledgeFrameworkFactory.getFramework(base)
|
||||
await framework.initialize(base)
|
||||
}
|
||||
public async reset(_: Electron.IpcMainInvokeEvent, { base }: { base: KnowledgeBaseParams }): Promise<void> {
|
||||
const framework = knowledgeFrameworkFactory.getFramework(base)
|
||||
await framework.reset(base)
|
||||
}
|
||||
|
||||
public async delete(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams, id: string): Promise<void> {
|
||||
logger.info(`Deleting knowledge base: ${JSON.stringify(base)}`)
|
||||
const framework = knowledgeFrameworkFactory.getFramework(base)
|
||||
await framework.delete(id)
|
||||
}
|
||||
|
||||
public add = async (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
|
||||
logger.info(`Adding item to knowledge base: ${JSON.stringify(options)}`)
|
||||
return new Promise((resolve) => {
|
||||
const { base, item, forceReload = false, userId = '' } = options
|
||||
const framework = knowledgeFrameworkFactory.getFramework(base)
|
||||
|
||||
const task = framework.getLoaderTask({ base, item, forceReload, userId })
|
||||
|
||||
if (task) {
|
||||
this.appendProcessingQueue(task).then(() => {
|
||||
resolve(task.loaderDoneReturn!)
|
||||
})
|
||||
this.processingQueueHandle()
|
||||
} else {
|
||||
resolve({
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: 'Unsupported item type',
|
||||
messageSource: 'embedding'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public async remove(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ uniqueIds, base }: { uniqueIds: string[]; base: KnowledgeBaseParams }
|
||||
): Promise<void> {
|
||||
logger.info(`Removing items from knowledge base: ${JSON.stringify({ uniqueIds, base })}`)
|
||||
const framework = knowledgeFrameworkFactory.getFramework(base)
|
||||
await framework.remove({ uniqueIds, base })
|
||||
}
|
||||
public async search(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ search, base }: { search: string; base: KnowledgeBaseParams }
|
||||
): Promise<KnowledgeSearchResult[]> {
|
||||
logger.info(`Searching knowledge base: ${JSON.stringify({ search, base })}`)
|
||||
const framework = knowledgeFrameworkFactory.getFramework(base)
|
||||
return framework.search({ search, base })
|
||||
}
|
||||
|
||||
@TraceMethod({ spanName: 'rerank', tag: 'Knowledge' })
|
||||
public async rerank(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ search, base, results }: { search: string; base: KnowledgeBaseParams; results: KnowledgeSearchResult[] }
|
||||
): Promise<KnowledgeSearchResult[]> {
|
||||
logger.info(`Reranking knowledge base: ${JSON.stringify({ search, base, results })}`)
|
||||
if (results.length === 0) {
|
||||
return results
|
||||
}
|
||||
return await new Reranker(base).rerank(search, results)
|
||||
}
|
||||
|
||||
public getStorageDir = (): string => {
|
||||
return this.storageDir
|
||||
}
|
||||
|
||||
public async checkQuota(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams, userId: string): Promise<number> {
|
||||
return preprocessingService.checkQuota(base, userId)
|
||||
}
|
||||
}
|
||||
|
||||
export default new KnowledgeService()
|
||||
@@ -1,555 +0,0 @@
|
||||
import * as fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { FaissStore } from '@langchain/community/vectorstores/faiss'
|
||||
import type { Document } from '@langchain/core/documents'
|
||||
import { loggerService } from '@logger'
|
||||
import TextEmbeddings from '@main/knowledge/langchain/embeddings/TextEmbeddings'
|
||||
import {
|
||||
addFileLoader,
|
||||
addNoteLoader,
|
||||
addSitemapLoader,
|
||||
addVideoLoader,
|
||||
addWebLoader
|
||||
} from '@main/knowledge/langchain/loader'
|
||||
import { RetrieverFactory } from '@main/knowledge/langchain/retriever'
|
||||
import { preprocessingService } from '@main/knowledge/preprocess/PreprocessingService'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
import { getUrlSource } from '@main/utils/knowledge'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { LoaderReturn } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import {
|
||||
FileMetadata,
|
||||
isKnowledgeDirectoryItem,
|
||||
isKnowledgeFileItem,
|
||||
isKnowledgeNoteItem,
|
||||
isKnowledgeSitemapItem,
|
||||
isKnowledgeUrlItem,
|
||||
isKnowledgeVideoItem,
|
||||
KnowledgeBaseParams,
|
||||
KnowledgeSearchResult
|
||||
} from '@types'
|
||||
import { uuidv4 } from 'zod/v4'
|
||||
|
||||
import { windowService } from '../WindowService'
|
||||
import {
|
||||
IKnowledgeFramework,
|
||||
KnowledgeBaseAddItemOptionsNonNullableAttribute,
|
||||
LoaderDoneReturn,
|
||||
LoaderTask,
|
||||
LoaderTaskItem,
|
||||
LoaderTaskItemState
|
||||
} from './IKnowledgeFramework'
|
||||
|
||||
const logger = loggerService.withContext('LangChainFramework')
|
||||
|
||||
export class LangChainFramework implements IKnowledgeFramework {
|
||||
private storageDir: string
|
||||
|
||||
private static ERROR_LOADER_RETURN: LoaderReturn = {
|
||||
entriesAdded: 0,
|
||||
uniqueId: '',
|
||||
uniqueIds: [''],
|
||||
loaderType: '',
|
||||
status: 'failed'
|
||||
}
|
||||
|
||||
constructor(storageDir: string) {
|
||||
this.storageDir = storageDir
|
||||
this.initStorageDir()
|
||||
}
|
||||
private initStorageDir = (): void => {
|
||||
if (!fs.existsSync(this.storageDir)) {
|
||||
fs.mkdirSync(this.storageDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private async createDatabase(base: KnowledgeBaseParams): Promise<void> {
|
||||
const dbPath = path.join(this.storageDir, base.id)
|
||||
const embeddings = this.getEmbeddings(base)
|
||||
const vectorStore = new FaissStore(embeddings, {})
|
||||
|
||||
const mockDocument: Document = {
|
||||
pageContent: 'Create Database Document',
|
||||
metadata: {}
|
||||
}
|
||||
|
||||
await vectorStore.addDocuments([mockDocument], { ids: ['1'] })
|
||||
await vectorStore.save(dbPath)
|
||||
await vectorStore.delete({ ids: ['1'] })
|
||||
await vectorStore.save(dbPath)
|
||||
}
|
||||
|
||||
private getEmbeddings(base: KnowledgeBaseParams): TextEmbeddings {
|
||||
return new TextEmbeddings({
|
||||
embedApiClient: base.embedApiClient,
|
||||
dimensions: base.dimensions
|
||||
})
|
||||
}
|
||||
|
||||
private async getVectorStore(base: KnowledgeBaseParams): Promise<FaissStore> {
|
||||
const embeddings = this.getEmbeddings(base)
|
||||
const vectorStore = await FaissStore.load(path.join(this.storageDir, base.id), embeddings)
|
||||
|
||||
return vectorStore
|
||||
}
|
||||
|
||||
async initialize(base: KnowledgeBaseParams): Promise<void> {
|
||||
await this.createDatabase(base)
|
||||
}
|
||||
async reset(base: KnowledgeBaseParams): Promise<void> {
|
||||
const dbPath = path.join(this.storageDir, base.id)
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.rmSync(dbPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const dbPath = path.join(this.storageDir, id)
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.rmSync(dbPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
getLoaderTask(options: KnowledgeBaseAddItemOptionsNonNullableAttribute): LoaderTask {
|
||||
const { item } = options
|
||||
const getStore = () => this.getVectorStore(options.base)
|
||||
switch (item.type) {
|
||||
case 'file':
|
||||
return this.fileTask(getStore, options)
|
||||
case 'directory':
|
||||
return this.directoryTask(getStore, options)
|
||||
case 'url':
|
||||
return this.urlTask(getStore, options)
|
||||
case 'sitemap':
|
||||
return this.sitemapTask(getStore, options)
|
||||
case 'note':
|
||||
return this.noteTask(getStore, options)
|
||||
case 'video':
|
||||
return this.videoTask(getStore, options)
|
||||
default:
|
||||
return {
|
||||
loaderTasks: [],
|
||||
loaderDoneReturn: null
|
||||
}
|
||||
}
|
||||
}
|
||||
async remove(options: { uniqueIds: string[]; base: KnowledgeBaseParams }): Promise<void> {
|
||||
const { uniqueIds, base } = options
|
||||
const vectorStore = await this.getVectorStore(base)
|
||||
logger.info(`[ KnowledgeService Remove Item UniqueIds: ${uniqueIds}]`)
|
||||
|
||||
await vectorStore.delete({ ids: uniqueIds })
|
||||
await vectorStore.save(path.join(this.storageDir, base.id))
|
||||
}
|
||||
async search(options: { search: string; base: KnowledgeBaseParams }): Promise<KnowledgeSearchResult[]> {
|
||||
const { search, base } = options
|
||||
logger.info(`search base: ${JSON.stringify(base)}`)
|
||||
|
||||
try {
|
||||
const vectorStore = await this.getVectorStore(base)
|
||||
|
||||
// 如果是 bm25 或 hybrid 模式,则从数据库获取所有文档
|
||||
const documents: Document[] = await this.getAllDocuments(base)
|
||||
if (documents.length === 0) return []
|
||||
|
||||
const retrieverFactory = new RetrieverFactory()
|
||||
const retriever = retrieverFactory.createRetriever(base, vectorStore, documents)
|
||||
|
||||
const results = await retriever.invoke(search)
|
||||
logger.info(`Search Results: ${JSON.stringify(results)}`)
|
||||
|
||||
// VectorStoreRetriever 和 EnsembleRetriever 会将分数附加到 metadata.score
|
||||
// BM25Retriever 默认不返回分数,所以我们需要处理这种情况
|
||||
return results.map((item) => {
|
||||
return {
|
||||
pageContent: item.pageContent,
|
||||
metadata: item.metadata,
|
||||
// 如果 metadata 中没有 score,提供一个默认值
|
||||
score: typeof item.metadata.score === 'number' ? item.metadata.score : 0
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`Error during search in knowledge base ${base.id}: ${error.message}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private fileTask(
|
||||
getVectorStore: () => Promise<FaissStore>,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, userId } = options
|
||||
|
||||
if (!isKnowledgeFileItem(item)) {
|
||||
logger.error(`Invalid item type for fileTask: expected 'file', got '${item.type}'`)
|
||||
return {
|
||||
loaderTasks: [],
|
||||
loaderDoneReturn: {
|
||||
...LangChainFramework.ERROR_LOADER_RETURN,
|
||||
message: `Invalid item type: expected 'file', got '${item.type}'`,
|
||||
messageSource: 'validation'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const file = item.content
|
||||
|
||||
const loaderTask: LoaderTask = {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: async () => {
|
||||
try {
|
||||
const vectorStore = await getVectorStore()
|
||||
|
||||
// 添加预处理逻辑
|
||||
const fileToProcess: FileMetadata = await preprocessingService.preprocessFile(file, base, item, userId)
|
||||
|
||||
// 使用处理后的文件进行加载
|
||||
return addFileLoader(base, vectorStore, fileToProcess)
|
||||
.then((result) => {
|
||||
loaderTask.loaderDoneReturn = result
|
||||
return result
|
||||
})
|
||||
.then(async () => {
|
||||
await vectorStore.save(path.join(this.storageDir, base.id))
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(`Error in addFileLoader for ${file.name}: ${e}`)
|
||||
const errorResult: LoaderReturn = {
|
||||
...LangChainFramework.ERROR_LOADER_RETURN,
|
||||
message: e.message,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
loaderTask.loaderDoneReturn = errorResult
|
||||
return errorResult
|
||||
})
|
||||
} catch (e: any) {
|
||||
logger.error(`Preprocessing failed for ${file.name}: ${e}`)
|
||||
const errorResult: LoaderReturn = {
|
||||
...LangChainFramework.ERROR_LOADER_RETURN,
|
||||
message: e.message,
|
||||
messageSource: 'preprocess'
|
||||
}
|
||||
loaderTask.loaderDoneReturn = errorResult
|
||||
return errorResult
|
||||
}
|
||||
},
|
||||
evaluateTaskWorkload: { workload: file.size }
|
||||
}
|
||||
],
|
||||
loaderDoneReturn: null
|
||||
}
|
||||
|
||||
return loaderTask
|
||||
}
|
||||
private directoryTask(
|
||||
getVectorStore: () => Promise<FaissStore>,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item } = options
|
||||
|
||||
if (!isKnowledgeDirectoryItem(item)) {
|
||||
logger.error(`Invalid item type for directoryTask: expected 'directory', got '${item.type}'`)
|
||||
return {
|
||||
loaderTasks: [],
|
||||
loaderDoneReturn: {
|
||||
...LangChainFramework.ERROR_LOADER_RETURN,
|
||||
message: `Invalid item type: expected 'directory', got '${item.type}'`,
|
||||
messageSource: 'validation'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const directory = item.content
|
||||
const files = getAllFiles(directory)
|
||||
const totalFiles = files.length
|
||||
let processedFiles = 0
|
||||
|
||||
const sendDirectoryProcessingPercent = (totalFiles: number, processedFiles: number) => {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
mainWindow?.webContents.send(IpcChannel.DirectoryProcessingPercent, {
|
||||
itemId: item.id,
|
||||
percent: (processedFiles / totalFiles) * 100
|
||||
})
|
||||
}
|
||||
|
||||
const loaderDoneReturn: LoaderDoneReturn = {
|
||||
entriesAdded: 0,
|
||||
uniqueId: `DirectoryLoader_${uuidv4()}`,
|
||||
uniqueIds: [],
|
||||
loaderType: 'DirectoryLoader'
|
||||
}
|
||||
const loaderTasks: LoaderTaskItem[] = []
|
||||
for (const file of files) {
|
||||
loaderTasks.push({
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: async () => {
|
||||
const vectorStore = await getVectorStore()
|
||||
return addFileLoader(base, vectorStore, file)
|
||||
.then((result) => {
|
||||
loaderDoneReturn.entriesAdded += 1
|
||||
processedFiles += 1
|
||||
sendDirectoryProcessingPercent(totalFiles, processedFiles)
|
||||
loaderDoneReturn.uniqueIds.push(result.uniqueId)
|
||||
return result
|
||||
})
|
||||
.then(async () => {
|
||||
await vectorStore.save(path.join(this.storageDir, base.id))
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(err)
|
||||
return {
|
||||
...LangChainFramework.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add dir loader: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
})
|
||||
},
|
||||
evaluateTaskWorkload: { workload: file.size }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
loaderTasks,
|
||||
loaderDoneReturn
|
||||
}
|
||||
}
|
||||
|
||||
private urlTask(
|
||||
getVectorStore: () => Promise<FaissStore>,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item } = options
|
||||
|
||||
if (!isKnowledgeUrlItem(item)) {
|
||||
logger.error(`Invalid item type for urlTask: expected 'url', got '${item.type}'`)
|
||||
return {
|
||||
loaderTasks: [],
|
||||
loaderDoneReturn: {
|
||||
...LangChainFramework.ERROR_LOADER_RETURN,
|
||||
message: `Invalid item type: expected 'url', got '${item.type}'`,
|
||||
messageSource: 'validation'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const url = item.content
|
||||
|
||||
const loaderTask: LoaderTask = {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: async () => {
|
||||
// 使用处理后的网页进行加载
|
||||
const vectorStore = await getVectorStore()
|
||||
return addWebLoader(base, vectorStore, url, getUrlSource(url))
|
||||
.then((result) => {
|
||||
loaderTask.loaderDoneReturn = result
|
||||
return result
|
||||
})
|
||||
.then(async () => {
|
||||
await vectorStore.save(path.join(this.storageDir, base.id))
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(`Error in addWebLoader for ${url}: ${e}`)
|
||||
const errorResult: LoaderReturn = {
|
||||
...LangChainFramework.ERROR_LOADER_RETURN,
|
||||
message: e.message,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
loaderTask.loaderDoneReturn = errorResult
|
||||
return errorResult
|
||||
})
|
||||
},
|
||||
evaluateTaskWorkload: { workload: 2 * MB }
|
||||
}
|
||||
],
|
||||
loaderDoneReturn: null
|
||||
}
|
||||
return loaderTask
|
||||
}
|
||||
|
||||
private sitemapTask(
|
||||
getVectorStore: () => Promise<FaissStore>,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item } = options
|
||||
|
||||
if (!isKnowledgeSitemapItem(item)) {
|
||||
logger.error(`Invalid item type for sitemapTask: expected 'sitemap', got '${item.type}'`)
|
||||
return {
|
||||
loaderTasks: [],
|
||||
loaderDoneReturn: {
|
||||
...LangChainFramework.ERROR_LOADER_RETURN,
|
||||
message: `Invalid item type: expected 'sitemap', got '${item.type}'`,
|
||||
messageSource: 'validation'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const url = item.content
|
||||
|
||||
const loaderTask: LoaderTask = {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: async () => {
|
||||
// 使用处理后的网页进行加载
|
||||
const vectorStore = await getVectorStore()
|
||||
return addSitemapLoader(base, vectorStore, url)
|
||||
.then((result) => {
|
||||
loaderTask.loaderDoneReturn = result
|
||||
return result
|
||||
})
|
||||
.then(async () => {
|
||||
await vectorStore.save(path.join(this.storageDir, base.id))
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(`Error in addWebLoader for ${url}: ${e}`)
|
||||
const errorResult: LoaderReturn = {
|
||||
...LangChainFramework.ERROR_LOADER_RETURN,
|
||||
message: e.message,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
loaderTask.loaderDoneReturn = errorResult
|
||||
return errorResult
|
||||
})
|
||||
},
|
||||
evaluateTaskWorkload: { workload: 2 * MB }
|
||||
}
|
||||
],
|
||||
loaderDoneReturn: null
|
||||
}
|
||||
return loaderTask
|
||||
}
|
||||
|
||||
private noteTask(
|
||||
getVectorStore: () => Promise<FaissStore>,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item } = options
|
||||
|
||||
if (!isKnowledgeNoteItem(item)) {
|
||||
logger.error(`Invalid item type for noteTask: expected 'note', got '${item.type}'`)
|
||||
return {
|
||||
loaderTasks: [],
|
||||
loaderDoneReturn: {
|
||||
...LangChainFramework.ERROR_LOADER_RETURN,
|
||||
message: `Invalid item type: expected 'note', got '${item.type}'`,
|
||||
messageSource: 'validation'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = item.content
|
||||
const sourceUrl = item.sourceUrl ?? ''
|
||||
|
||||
logger.info(`noteTask ${content}, ${sourceUrl}`)
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const contentBytes = encoder.encode(content)
|
||||
const loaderTask: LoaderTask = {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: async () => {
|
||||
// 使用处理后的笔记进行加载
|
||||
const vectorStore = await getVectorStore()
|
||||
return addNoteLoader(base, vectorStore, content, sourceUrl)
|
||||
.then((result) => {
|
||||
loaderTask.loaderDoneReturn = result
|
||||
return result
|
||||
})
|
||||
.then(async () => {
|
||||
await vectorStore.save(path.join(this.storageDir, base.id))
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(`Error in addNoteLoader for ${sourceUrl}: ${e}`)
|
||||
const errorResult: LoaderReturn = {
|
||||
...LangChainFramework.ERROR_LOADER_RETURN,
|
||||
message: e.message,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
loaderTask.loaderDoneReturn = errorResult
|
||||
return errorResult
|
||||
})
|
||||
},
|
||||
evaluateTaskWorkload: { workload: contentBytes.length }
|
||||
}
|
||||
],
|
||||
loaderDoneReturn: null
|
||||
}
|
||||
return loaderTask
|
||||
}
|
||||
|
||||
private videoTask(
|
||||
getVectorStore: () => Promise<FaissStore>,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item } = options
|
||||
|
||||
if (!isKnowledgeVideoItem(item)) {
|
||||
logger.error(`Invalid item type for videoTask: expected 'video', got '${item.type}'`)
|
||||
return {
|
||||
loaderTasks: [],
|
||||
loaderDoneReturn: {
|
||||
...LangChainFramework.ERROR_LOADER_RETURN,
|
||||
message: `Invalid item type: expected 'video', got '${item.type}'`,
|
||||
messageSource: 'validation'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const files = item.content
|
||||
|
||||
const loaderTask: LoaderTask = {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: async () => {
|
||||
const vectorStore = await getVectorStore()
|
||||
return addVideoLoader(base, vectorStore, files)
|
||||
.then((result) => {
|
||||
loaderTask.loaderDoneReturn = result
|
||||
return result
|
||||
})
|
||||
.then(async () => {
|
||||
await vectorStore.save(path.join(this.storageDir, base.id))
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(`Preprocessing failed for ${files[0].name}: ${e}`)
|
||||
const errorResult: LoaderReturn = {
|
||||
...LangChainFramework.ERROR_LOADER_RETURN,
|
||||
message: e.message,
|
||||
messageSource: 'preprocess'
|
||||
}
|
||||
loaderTask.loaderDoneReturn = errorResult
|
||||
return errorResult
|
||||
})
|
||||
},
|
||||
evaluateTaskWorkload: { workload: files[0].size }
|
||||
}
|
||||
],
|
||||
loaderDoneReturn: null
|
||||
}
|
||||
return loaderTask
|
||||
}
|
||||
|
||||
private async getAllDocuments(base: KnowledgeBaseParams): Promise<Document[]> {
|
||||
logger.info(`Fetching all documents from database for knowledge base: ${base.id}`)
|
||||
|
||||
try {
|
||||
const results = (await this.getVectorStore(base)).docstore._docs
|
||||
|
||||
const documents: Document[] = Array.from(results.values())
|
||||
logger.info(`Fetched ${documents.length} documents for BM25/Hybrid retriever.`)
|
||||
return documents
|
||||
} catch (e) {
|
||||
logger.error(`Could not fetch documents from database for base ${base.id}: ${e}`)
|
||||
// 如果表不存在或查询失败,返回空数组
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Client, createClient } from '@libsql/client'
|
||||
import { loggerService } from '@logger'
|
||||
import Embeddings from '@main/knowledge/embedjs/embeddings/Embeddings'
|
||||
import Embeddings from '@main/knowledge/embeddings/Embeddings'
|
||||
import type {
|
||||
AddMemoryOptions,
|
||||
AssistantMessage,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { loggerService } from '@logger'
|
||||
import { isLinux } from '@main/constant'
|
||||
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
|
||||
|
||||
import { ppocrService } from './builtin/PpocrService'
|
||||
import { systemOcrService } from './builtin/SystemOcrService'
|
||||
import { tesseractService } from './builtin/TesseractService'
|
||||
|
||||
@@ -37,5 +36,3 @@ export const ocrService = new OcrService()
|
||||
ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService))
|
||||
|
||||
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
|
||||
|
||||
ocrService.register(BuiltinOcrProviderIds.paddleocr, ppocrService.ocr.bind(ppocrService))
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { loadOcrImage } from '@main/utils/ocr'
|
||||
import { ImageFileMetadata, isImageFileMetadata, OcrPpocrConfig, OcrResult, SupportedOcrFile } from '@types'
|
||||
import { net } from 'electron'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { OcrBaseService } from './OcrBaseService'
|
||||
|
||||
enum FileType {
|
||||
PDF = 0,
|
||||
Image = 1
|
||||
}
|
||||
|
||||
// API Reference: https://www.paddleocr.ai/latest/version3.x/pipeline_usage/OCR.html#3
|
||||
interface OcrPayload {
|
||||
file: string
|
||||
fileType?: FileType | null
|
||||
useDocOrientationClassify?: boolean | null
|
||||
useDocUnwarping?: boolean | null
|
||||
useTextlineOrientation?: boolean | null
|
||||
textDetLimitSideLen?: number | null
|
||||
textDetLimitType?: string | null
|
||||
textDetThresh?: number | null
|
||||
textDetBoxThresh?: number | null
|
||||
textDetUnclipRatio?: number | null
|
||||
textRecScoreThresh?: number | null
|
||||
visualize?: boolean | null
|
||||
}
|
||||
|
||||
const OcrResponseSchema = z.object({
|
||||
result: z.object({
|
||||
ocrResults: z.array(
|
||||
z.object({
|
||||
prunedResult: z.object({
|
||||
rec_texts: z.array(z.string())
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
export class PpocrService extends OcrBaseService {
|
||||
public ocr = async (file: SupportedOcrFile, options?: OcrPpocrConfig): Promise<OcrResult> => {
|
||||
if (!isImageFileMetadata(file)) {
|
||||
throw new Error('Only image files are supported currently')
|
||||
}
|
||||
if (!options) {
|
||||
throw new Error('config is required')
|
||||
}
|
||||
return this.imageOcr(file, options)
|
||||
}
|
||||
|
||||
private async imageOcr(file: ImageFileMetadata, options: OcrPpocrConfig): Promise<OcrResult> {
|
||||
if (!options.apiUrl) {
|
||||
throw new Error('API URL is required')
|
||||
}
|
||||
const apiUrl = options.apiUrl
|
||||
|
||||
const buffer = await loadOcrImage(file)
|
||||
const base64 = buffer.toString('base64')
|
||||
const payload = {
|
||||
file: base64,
|
||||
fileType: FileType.Image,
|
||||
useDocOrientationClassify: false,
|
||||
useDocUnwarping: false,
|
||||
visualize: false
|
||||
} satisfies OcrPayload
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
if (options.accessToken) {
|
||||
headers['Authorization'] = `token ${options.accessToken}`
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await net.fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(`OCR service error: ${response.status} ${response.statusText} - ${text}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const validatedResponse = OcrResponseSchema.parse(data)
|
||||
const recTexts = validatedResponse.result.ocrResults[0].prunedResult.rec_texts
|
||||
|
||||
return { text: recTexts.join('\n') }
|
||||
} catch (error: any) {
|
||||
throw new Error(`OCR service error: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ppocrService = new PpocrService()
|
||||
@@ -205,19 +205,6 @@ export async function readTextFileWithAutoEncoding(filePath: string): Promise<st
|
||||
return iconv.decode(data, 'UTF-8')
|
||||
}
|
||||
|
||||
export async function base64Image(file: FileMetadata): Promise<{ mime: string; base64: string; data: string }> {
|
||||
const filePath = path.join(getFilesDir(), `${file.id}${file.ext}`)
|
||||
const data = await fs.promises.readFile(filePath)
|
||||
const base64 = data.toString('base64')
|
||||
const ext = path.extname(filePath).slice(1) == 'jpg' ? 'jpeg' : path.extname(filePath).slice(1)
|
||||
const mime = `image/${ext}`
|
||||
return {
|
||||
mime,
|
||||
base64,
|
||||
data: `data:${mime};base64,${base64}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归扫描目录,获取符合条件的文件和目录结构
|
||||
* @param dirPath 当前要扫描的路径
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export const DEFAULT_DOCUMENT_COUNT = 6
|
||||
export const DEFAULT_RELEVANT_SCORE = 0
|
||||
export type UrlSource = 'normal' | 'github' | 'youtube'
|
||||
|
||||
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be|youtube\.be|yt\.be)/i
|
||||
|
||||
export function getUrlSource(url: string): UrlSource {
|
||||
if (youtubeRegex.test(url)) {
|
||||
return 'youtube'
|
||||
} else {
|
||||
return 'normal'
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import EnUs from '../../renderer/src/i18n/locales/en-us.json'
|
||||
import JaJP from '../../renderer/src/i18n/locales/ja-jp.json'
|
||||
import RuRu from '../../renderer/src/i18n/locales/ru-ru.json'
|
||||
import ZhCn from '../../renderer/src/i18n/locales/zh-cn.json'
|
||||
import ZhTw from '../../renderer/src/i18n/locales/zh-tw.json'
|
||||
// Machine translation
|
||||
import elGR from '../../renderer/src/i18n/translate/el-gr.json'
|
||||
import esES from '../../renderer/src/i18n/translate/es-es.json'
|
||||
import frFR from '../../renderer/src/i18n/translate/fr-fr.json'
|
||||
import JaJP from '../../renderer/src/i18n/translate/ja-jp.json'
|
||||
import ptPT from '../../renderer/src/i18n/translate/pt-pt.json'
|
||||
import RuRu from '../../renderer/src/i18n/translate/ru-ru.json'
|
||||
|
||||
const locales = Object.fromEntries(
|
||||
[
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||
import { SpanContext } from '@opentelemetry/api'
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
FileUploadResponse,
|
||||
KnowledgeBaseParams,
|
||||
KnowledgeItem,
|
||||
KnowledgeSearchResult,
|
||||
MCPServer,
|
||||
MemoryConfig,
|
||||
MemoryListOptions,
|
||||
@@ -166,8 +166,7 @@ const api = {
|
||||
selectFolder: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_SelectFolder, options),
|
||||
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
|
||||
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
|
||||
base64Image: (fileId: string): Promise<{ mime: string; base64: string; data: string }> =>
|
||||
ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
|
||||
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
|
||||
saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data),
|
||||
savePastedImage: (imageData: Uint8Array, extension?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_SavePastedImage, imageData, extension),
|
||||
@@ -216,7 +215,7 @@ const api = {
|
||||
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
|
||||
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
|
||||
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
|
||||
delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
|
||||
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
|
||||
add: ({
|
||||
base,
|
||||
item,
|
||||
@@ -233,7 +232,7 @@ const api = {
|
||||
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }, context?: SpanContext) =>
|
||||
tracedInvoke(IpcChannel.KnowledgeBase_Search, context, { search, base }),
|
||||
rerank: (
|
||||
{ search, base, results }: { search: string; base: KnowledgeBaseParams; results: KnowledgeSearchResult[] },
|
||||
{ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] },
|
||||
context?: SpanContext
|
||||
) => tracedInvoke(IpcChannel.KnowledgeBase_Rerank, context, { search, base, results }),
|
||||
checkQuota: ({ base, userId }: { base: KnowledgeBaseParams; userId: string }) =>
|
||||
@@ -439,19 +438,8 @@ const api = {
|
||||
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Cherryin_GetSignature, params)
|
||||
},
|
||||
windowControls: {
|
||||
minimize: (): Promise<void> => ipcRenderer.invoke(IpcChannel.Windows_Minimize),
|
||||
maximize: (): Promise<void> => ipcRenderer.invoke(IpcChannel.Windows_Maximize),
|
||||
unmaximize: (): Promise<void> => ipcRenderer.invoke(IpcChannel.Windows_Unmaximize),
|
||||
close: (): Promise<void> => ipcRenderer.invoke(IpcChannel.Windows_Close),
|
||||
isMaximized: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.Windows_IsMaximized),
|
||||
onMaximizedChange: (callback: (isMaximized: boolean) => void): (() => void) => {
|
||||
const channel = IpcChannel.Windows_MaximizedChanged
|
||||
ipcRenderer.on(channel, (_, isMaximized: boolean) => callback(isMaximized))
|
||||
return () => {
|
||||
ipcRenderer.removeAllListeners(channel)
|
||||
}
|
||||
}
|
||||
provider: {
|
||||
getClaudeCodePort: () => ipcRenderer.invoke(IpcChannel.Provider_GetClaudeCodePort)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' 'unsafe-inline' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; media-src 'self' file:; frame-src * file:" />
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' 'unsafe-inline' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -14,7 +14,6 @@ import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
import LaunchpadPage from './pages/launchpad/LaunchpadPage'
|
||||
import MinAppPage from './pages/minapps/MinAppPage'
|
||||
import MinAppsPage from './pages/minapps/MinAppsPage'
|
||||
import NotesPage from './pages/notes/NotesPage'
|
||||
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
||||
@@ -35,7 +34,6 @@ const Router: FC = () => {
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/notes" element={<NotesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps/:appId" element={<MinAppPage />} />
|
||||
<Route path="/apps" element={<MinAppsPage />} />
|
||||
<Route path="/code" element={<CodeToolsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
|
||||
@@ -53,6 +53,54 @@ export class AiSdkToChunkAdapter {
|
||||
return await aiSdkResult.text
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接处理单个 chunk 数据
|
||||
* @param chunk AI SDK 的 chunk 数据
|
||||
*/
|
||||
async processChunk(response: ReadableStream<TextStreamPart<any>>): Promise<void> {
|
||||
const reader = response.getReader()
|
||||
const final = {
|
||||
text: '',
|
||||
reasoningContent: '',
|
||||
webSearchResults: [],
|
||||
reasoningId: ''
|
||||
}
|
||||
try {
|
||||
let buffer = ''
|
||||
const decoder = new TextDecoder()
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
buffer += chunk
|
||||
|
||||
// 按行处理 SSE 数据
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || '' // 保留最后一行(可能不完整)
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const dataStr = line.slice(6) // 移除 "data: " 前缀
|
||||
|
||||
if (dataStr === '[DONE]') {
|
||||
break
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(dataStr)
|
||||
this.convertAndEmitChunk(data, final)
|
||||
} catch (parseError) {
|
||||
// 忽略无法解析的数据
|
||||
// logger.debug('Failed to parse streamed data:', parseError as Error, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 fullStream 并转换为 Cherry Studio chunks
|
||||
* @param fullStream AI SDK 的 fullStream (ReadableStream)
|
||||
@@ -90,6 +138,7 @@ export class AiSdkToChunkAdapter {
|
||||
final: { text: string; reasoningContent: string; webSearchResults: any[]; reasoningId: string }
|
||||
) {
|
||||
logger.info(`AI SDK chunk type: ${chunk.type}`, chunk)
|
||||
console.log('final', final)
|
||||
switch (chunk.type) {
|
||||
// === 文本相关事件 ===
|
||||
case 'text-start':
|
||||
@@ -99,7 +148,7 @@ export class AiSdkToChunkAdapter {
|
||||
break
|
||||
case 'text-delta':
|
||||
if (this.accumulate) {
|
||||
final.text += chunk.text || ''
|
||||
final.text += chunk.delta || ''
|
||||
} else {
|
||||
final.text = chunk.text || ''
|
||||
}
|
||||
@@ -232,13 +281,13 @@ export class AiSdkToChunkAdapter {
|
||||
text: final.text || '',
|
||||
reasoning_content: final.reasoningContent || '',
|
||||
usage: {
|
||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
||||
prompt_tokens: chunk.totalUsage.inputTokens || 0,
|
||||
total_tokens: chunk.totalUsage.totalTokens || 0
|
||||
completion_tokens: chunk?.totalUsage?.outputTokens || 0,
|
||||
prompt_tokens: chunk?.totalUsage?.inputTokens || 0,
|
||||
total_tokens: chunk?.totalUsage?.totalTokens || 0
|
||||
},
|
||||
metrics: chunk.totalUsage
|
||||
metrics: chunk?.totalUsage
|
||||
? {
|
||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
||||
completion_tokens: chunk?.totalUsage?.outputTokens || 0,
|
||||
time_completion_millsec: 0
|
||||
}
|
||||
: undefined
|
||||
@@ -250,13 +299,13 @@ export class AiSdkToChunkAdapter {
|
||||
text: final.text || '',
|
||||
reasoning_content: final.reasoningContent || '',
|
||||
usage: {
|
||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
||||
prompt_tokens: chunk.totalUsage.inputTokens || 0,
|
||||
total_tokens: chunk.totalUsage.totalTokens || 0
|
||||
completion_tokens: chunk?.totalUsage?.outputTokens || 0,
|
||||
prompt_tokens: chunk?.totalUsage?.inputTokens || 0,
|
||||
total_tokens: chunk?.totalUsage?.totalTokens || 0
|
||||
},
|
||||
metrics: chunk.totalUsage
|
||||
metrics: chunk?.totalUsage
|
||||
? {
|
||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
||||
completion_tokens: chunk?.totalUsage?.outputTokens || 0,
|
||||
time_completion_millsec: 0
|
||||
}
|
||||
: undefined
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { processKnowledgeReferences } from '@renderer/services/KnowledgeService'
|
||||
import { BaseTool, MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types'
|
||||
import { Chunk, ChunkType } from '@renderer/types/chunk'
|
||||
import type { ProviderMetadata, ToolSet, TypedToolCall, TypedToolResult } from 'ai'
|
||||
@@ -253,18 +252,6 @@ export class ToolCallChunkHandler {
|
||||
response: output,
|
||||
toolCallId: toolCallId
|
||||
}
|
||||
|
||||
// 工具特定的后处理
|
||||
switch (toolResponse.tool.name) {
|
||||
case 'builtin_knowledge_search': {
|
||||
processKnowledgeReferences(toolResponse.response?.knowledgeReferences, this.onChunk)
|
||||
break
|
||||
}
|
||||
// 未来可以在这里添加其他工具的后处理逻辑
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// 从活跃调用中移除(交互结束后整个实例会被丢弃)
|
||||
this.activeToolCalls.delete(toolCallId)
|
||||
|
||||
|
||||
@@ -90,6 +90,11 @@ export default class ModernAiProvider {
|
||||
// 准备特殊配置
|
||||
await prepareSpecialProviderConfig(this.actualProvider, this.config)
|
||||
|
||||
// 特殊处理 claude-code provider,通过本地 HTTP 服务器
|
||||
// if (this.config.providerId === 'claude-code') {
|
||||
return await this._completionsViaHttpService(modelId, params, config)
|
||||
// }
|
||||
|
||||
// 提前创建本地 provider 实例
|
||||
if (!this.localProvider) {
|
||||
this.localProvider = await createAiSdkProvider(this.config)
|
||||
@@ -246,6 +251,79 @@ export default class ModernAiProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过本地 HTTP 服务器处理 claude-code completions
|
||||
*/
|
||||
private async _completionsViaHttpService(
|
||||
modelId: string,
|
||||
params: StreamTextParams,
|
||||
config: ModernAiProviderConfig
|
||||
): Promise<CompletionsResult> {
|
||||
logger.info('Starting claude-code completions via HTTP service', {
|
||||
modelId,
|
||||
providerId: this.config!.providerId,
|
||||
topicId: config.topicId,
|
||||
hasOnChunk: !!config.onChunk
|
||||
})
|
||||
|
||||
try {
|
||||
// 初始化 claude-code provider
|
||||
const initResponse = await fetch('http://localhost:' + (await this.getClaudeCodePort()) + '/init', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.config!.options)
|
||||
})
|
||||
|
||||
if (!initResponse.ok) {
|
||||
throw new Error(`Failed to initialize claude-code provider: ${initResponse.statusText}`)
|
||||
}
|
||||
|
||||
// 发送 completions 请求
|
||||
const completionsResponse = await fetch('http://localhost:' + (await this.getClaudeCodePort()) + '/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
modelId,
|
||||
params,
|
||||
options: this.config!.options
|
||||
})
|
||||
})
|
||||
|
||||
if (!completionsResponse.ok) {
|
||||
throw new Error(`Failed to get completions: ${completionsResponse.statusText}`)
|
||||
}
|
||||
|
||||
let finalText = ''
|
||||
|
||||
if (config.onChunk && completionsResponse.body) {
|
||||
// 创建 adapter 来处理 chunk 数据
|
||||
const accumulate = this.model!.supported_text_delta !== false
|
||||
const adapter = new AiSdkToChunkAdapter(config.onChunk, config.mcpTools, accumulate)
|
||||
await adapter.processChunk(completionsResponse.body)
|
||||
} else {
|
||||
finalText = await completionsResponse.text()
|
||||
}
|
||||
|
||||
return {
|
||||
getText: () => finalText
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in claude-code HTTP service completions', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Claude-code HTTP 服务端口
|
||||
*/
|
||||
private async getClaudeCodePort(): Promise<number> {
|
||||
return await window.api.provider.getClaudeCodePort()
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用现代化AI SDK的completions实现
|
||||
*/
|
||||
|
||||
@@ -256,27 +256,19 @@ export abstract class BaseApiClient<
|
||||
return defaultTimeout
|
||||
}
|
||||
|
||||
public async getMessageContent(
|
||||
message: Message
|
||||
): Promise<{ textContent: string; imageContents: { fileId: string; fileExt: string }[] }> {
|
||||
public async getMessageContent(message: Message): Promise<string> {
|
||||
const content = getMainTextContent(message)
|
||||
|
||||
if (isEmpty(content)) {
|
||||
return {
|
||||
textContent: '',
|
||||
imageContents: []
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const webSearchReferences = await this.getWebSearchReferencesFromCache(message)
|
||||
const knowledgeReferences = await this.getKnowledgeBaseReferencesFromCache(message)
|
||||
const memoryReferences = this.getMemoryReferencesFromCache(message)
|
||||
|
||||
const knowledgeTextReferences = knowledgeReferences.filter((k) => k.metadata?.type !== 'image')
|
||||
const knowledgeImageReferences = knowledgeReferences.filter((k) => k.metadata?.type === 'image')
|
||||
|
||||
// 添加偏移量以避免ID冲突
|
||||
const reindexedKnowledgeReferences = knowledgeTextReferences.map((ref) => ({
|
||||
const reindexedKnowledgeReferences = knowledgeReferences.map((ref) => ({
|
||||
...ref,
|
||||
id: ref.id + webSearchReferences.length // 为知识库引用的ID添加网络搜索引用的数量作为偏移量
|
||||
}))
|
||||
@@ -285,17 +277,12 @@ export abstract class BaseApiClient<
|
||||
|
||||
logger.debug(`Found ${allReferences.length} references for ID: ${message.id}`, allReferences)
|
||||
|
||||
const referenceContent = `\`\`\`json\n${JSON.stringify(allReferences, null, 2)}\n\`\`\``
|
||||
const imageReferences = knowledgeImageReferences.map((r) => {
|
||||
return { fileId: r.metadata?.id, fileExt: r.metadata?.ext }
|
||||
})
|
||||
|
||||
return {
|
||||
textContent: isEmpty(allReferences)
|
||||
? content
|
||||
: REFERENCE_PROMPT.replace('{question}', content).replace('{references}', referenceContent),
|
||||
imageContents: isEmpty(knowledgeImageReferences) ? [] : imageReferences
|
||||
if (!isEmpty(allReferences)) {
|
||||
const referenceContent = `\`\`\`json\n${JSON.stringify(allReferences, null, 2)}\n\`\`\``
|
||||
return REFERENCE_PROMPT.replace('{question}', content).replace('{references}', referenceContent)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -187,10 +187,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
}
|
||||
}
|
||||
|
||||
private static isValidBase64ImageMediaType(mime: string): mime is Base64ImageSource['media_type'] {
|
||||
return ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(mime)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message parameter
|
||||
* @param message - The message
|
||||
@@ -198,34 +194,13 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
* @returns The message parameter
|
||||
*/
|
||||
public async convertMessageToSdkParam(message: Message): Promise<AnthropicSdkMessageParam> {
|
||||
const { textContent, imageContents } = await this.getMessageContent(message)
|
||||
|
||||
const parts: MessageParam['content'] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: textContent
|
||||
text: await this.getMessageContent(message)
|
||||
}
|
||||
]
|
||||
|
||||
if (imageContents.length > 0) {
|
||||
for (const imageContent of imageContents) {
|
||||
const base64Data = await window.api.file.base64Image(imageContent.fileId + imageContent.fileExt)
|
||||
base64Data.mime = base64Data.mime.replace('jpg', 'jpeg')
|
||||
if (AnthropicAPIClient.isValidBase64ImageMediaType(base64Data.mime)) {
|
||||
parts.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
data: base64Data.base64,
|
||||
media_type: base64Data.mime,
|
||||
type: 'base64'
|
||||
}
|
||||
})
|
||||
} else {
|
||||
logger.warn('Unsupported image type, ignored.', { mime: base64Data.mime })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get and process image blocks
|
||||
const imageBlocks = findImageBlocks(message)
|
||||
for (const imageBlock of imageBlocks) {
|
||||
|
||||
@@ -622,7 +622,7 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
}
|
||||
|
||||
public async convertMessageToSdkParam(message: Message): Promise<AwsBedrockSdkMessageParam> {
|
||||
const { textContent, imageContents } = await this.getMessageContent(message)
|
||||
const content = await this.getMessageContent(message)
|
||||
const parts: Array<{
|
||||
text?: string
|
||||
image?: {
|
||||
@@ -638,29 +638,8 @@ export class AwsBedrockAPIClient extends BaseApiClient<
|
||||
}> = []
|
||||
|
||||
// 添加文本内容 - 只在有非空内容时添加
|
||||
if (textContent && textContent.trim()) {
|
||||
parts.push({ text: textContent })
|
||||
}
|
||||
|
||||
if (imageContents.length > 0) {
|
||||
for (const imageContent of imageContents) {
|
||||
try {
|
||||
const image = await window.api.file.base64Image(imageContent.fileId + imageContent.fileExt)
|
||||
const mimeType = image.mime || 'image/png'
|
||||
const base64Data = image.base64
|
||||
|
||||
const awsImage = convertBase64ImageToAwsBedrockFormat(base64Data, mimeType)
|
||||
if (awsImage) {
|
||||
parts.push({ image: awsImage })
|
||||
} else {
|
||||
// 不支持的格式,转换为文本描述
|
||||
parts.push({ text: `[Image: ${mimeType}]` })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing image:', error as Error)
|
||||
parts.push({ text: '[Image processing failed]' })
|
||||
}
|
||||
}
|
||||
if (content && content.trim()) {
|
||||
parts.push({ text: content })
|
||||
}
|
||||
|
||||
// 处理图片内容
|
||||
|
||||
@@ -211,7 +211,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
inlineData: {
|
||||
data,
|
||||
mimeType
|
||||
}
|
||||
} as Part['inlineData']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,22 +225,8 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
|
||||
// If file is not found, upload it to Gemini
|
||||
const result = await window.api.fileService.upload(this.provider, file)
|
||||
const remoteFile = result.originalFile
|
||||
if (!remoteFile) {
|
||||
throw new Error('File upload failed, please try again')
|
||||
}
|
||||
if (remoteFile.type === 'gemini') {
|
||||
const file = remoteFile.file
|
||||
if (!file.uri) {
|
||||
throw new Error('File URI is required but not found')
|
||||
}
|
||||
if (!file.mimeType) {
|
||||
throw new Error('File MIME type is required but not found')
|
||||
}
|
||||
return createPartFromUri(file.uri, file.mimeType)
|
||||
} else {
|
||||
throw new Error('Unsupported file type for Gemini API')
|
||||
}
|
||||
const remoteFile = result.originalFile?.file as File
|
||||
return createPartFromUri(remoteFile.uri!, remoteFile.mimeType!)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,20 +236,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
*/
|
||||
private async convertMessageToSdkParam(message: Message): Promise<Content> {
|
||||
const role = message.role === 'user' ? 'user' : 'model'
|
||||
const { textContent, imageContents } = await this.getMessageContent(message)
|
||||
const parts: Part[] = [{ text: textContent }]
|
||||
|
||||
if (imageContents.length > 0) {
|
||||
for (const imageContent of imageContents) {
|
||||
const image = await window.api.file.base64Image(imageContent.fileId + imageContent.fileExt)
|
||||
parts.push({
|
||||
inlineData: {
|
||||
data: image.base64,
|
||||
mimeType: image.mime
|
||||
} satisfies Part['inlineData']
|
||||
})
|
||||
}
|
||||
}
|
||||
const parts: Part[] = [{ text: await this.getMessageContent(message) }]
|
||||
|
||||
// Add any generated images from previous responses
|
||||
const imageBlocks = findImageBlocks(message)
|
||||
@@ -283,7 +256,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
inlineData: {
|
||||
data: base64Data,
|
||||
mimeType: mimeType
|
||||
} satisfies Part['inlineData']
|
||||
} as Part['inlineData']
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -296,7 +269,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
inlineData: {
|
||||
data: base64Data.base64,
|
||||
mimeType: base64Data.mime
|
||||
} satisfies Part['inlineData']
|
||||
} as Part['inlineData']
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -310,7 +283,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
inlineData: {
|
||||
data: base64Data.base64,
|
||||
mimeType: base64Data.mime
|
||||
} satisfies Part['inlineData']
|
||||
} as Part['inlineData']
|
||||
})
|
||||
}
|
||||
|
||||
@@ -354,7 +327,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
inlineData: {
|
||||
data: base64Data,
|
||||
mimeType: mimeType
|
||||
} satisfies Part['inlineData']
|
||||
} as Part['inlineData']
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -367,7 +340,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
inlineData: {
|
||||
data: base64Data.base64,
|
||||
mimeType: base64Data.mime
|
||||
} satisfies Part['inlineData']
|
||||
} as Part['inlineData']
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -382,7 +355,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
* @returns The safety settings
|
||||
*/
|
||||
private getSafetySettings(): SafetySetting[] {
|
||||
const safetyThreshold = HarmBlockThreshold.OFF
|
||||
const safetyThreshold = 'OFF' as HarmBlockThreshold
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -446,7 +419,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
thinkingConfig: {
|
||||
...(budget > 0 ? { thinkingBudget: budget } : {}),
|
||||
includeThoughts: true
|
||||
} satisfies ThinkingConfig
|
||||
} as ThinkingConfig
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,7 +496,9 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
const isFirstMessage = history.length === 0
|
||||
if (isFirstMessage && messageContents) {
|
||||
const userMessageText =
|
||||
messageContents.parts && messageContents.parts.length > 0 ? (messageContents.parts[0].text ?? '') : ''
|
||||
messageContents.parts && messageContents.parts.length > 0
|
||||
? (messageContents.parts[0] as Part).text || ''
|
||||
: ''
|
||||
const systemMessage = [
|
||||
{
|
||||
text:
|
||||
@@ -534,7 +509,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
userMessageText +
|
||||
'<end_of_turn>'
|
||||
}
|
||||
] satisfies Part[]
|
||||
] as Part[]
|
||||
if (messageContents && messageContents.parts) {
|
||||
messageContents.parts[0] = systemMessage[0]
|
||||
}
|
||||
@@ -605,7 +580,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} satisfies ThinkingStartChunk)
|
||||
} as ThinkingStartChunk)
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
@@ -616,7 +591,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} satisfies TextStartChunk)
|
||||
} as TextStartChunk)
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
@@ -649,7 +624,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
results: candidate.groundingMetadata,
|
||||
source: WebSearchSource.GEMINI
|
||||
}
|
||||
} satisfies LLMWebSearchCompleteChunk)
|
||||
} as LLMWebSearchCompleteChunk)
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
@@ -706,7 +681,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
tool: mcpTool,
|
||||
arguments: parsedArgs,
|
||||
status: 'pending'
|
||||
} satisfies ToolCallResponse
|
||||
} as ToolCallResponse
|
||||
}
|
||||
|
||||
public convertMcpToolResponseToSdkMessageParam(
|
||||
|
||||
@@ -379,40 +379,32 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
*/
|
||||
public async convertMessageToSdkParam(message: Message, model: Model): Promise<OpenAISdkMessageParam> {
|
||||
const isVision = isVisionModel(model)
|
||||
const { textContent, imageContents } = await this.getMessageContent(message)
|
||||
const content = await this.getMessageContent(message)
|
||||
const fileBlocks = findFileBlocks(message)
|
||||
const imageBlocks = findImageBlocks(message)
|
||||
|
||||
if (fileBlocks.length === 0 && imageBlocks.length === 0) {
|
||||
return {
|
||||
role: message.role === 'system' ? 'user' : message.role,
|
||||
content
|
||||
} as OpenAISdkMessageParam
|
||||
}
|
||||
|
||||
// If the model does not support files, extract the file content
|
||||
if (this.isNotSupportFiles) {
|
||||
const fileContent = await this.extractFileContent(message)
|
||||
|
||||
return {
|
||||
role: message.role === 'system' ? 'user' : message.role,
|
||||
content: textContent + '\n\n---\n\n' + fileContent
|
||||
} as OpenAISdkMessageParam
|
||||
}
|
||||
|
||||
// Check if we only have text content and no other media
|
||||
if (fileBlocks.length === 0 && imageBlocks.length === 0 && imageContents.length === 0) {
|
||||
return {
|
||||
role: message.role === 'system' ? 'user' : message.role,
|
||||
content: textContent
|
||||
content: content + '\n\n---\n\n' + fileContent
|
||||
} as OpenAISdkMessageParam
|
||||
}
|
||||
|
||||
// If the model supports files, add the file content to the message
|
||||
const parts: ChatCompletionContentPart[] = []
|
||||
|
||||
if (textContent) {
|
||||
parts.push({ type: 'text', text: textContent })
|
||||
}
|
||||
|
||||
if (imageContents.length > 0) {
|
||||
for (const imageContent of imageContents) {
|
||||
const image = await window.api.file.base64Image(imageContent.fileId + imageContent.fileExt)
|
||||
parts.push({ type: 'image_url', image_url: { url: image.data } })
|
||||
}
|
||||
if (content) {
|
||||
parts.push({ type: 'text', text: content })
|
||||
}
|
||||
|
||||
for (const imageBlock of imageBlocks) {
|
||||
|
||||
@@ -171,43 +171,32 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
|
||||
public async convertMessageToSdkParam(message: Message, model: Model): Promise<OpenAIResponseSdkMessageParam> {
|
||||
const isVision = isVisionModel(model)
|
||||
const { textContent, imageContents } = await this.getMessageContent(message)
|
||||
const content = await this.getMessageContent(message)
|
||||
const fileBlocks = findFileBlocks(message)
|
||||
const imageBlocks = findImageBlocks(message)
|
||||
|
||||
if (fileBlocks.length === 0 && imageBlocks.length === 0 && imageContents.length === 0) {
|
||||
if (fileBlocks.length === 0 && imageBlocks.length === 0) {
|
||||
if (message.role === 'assistant') {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: textContent
|
||||
content: content
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
role: message.role === 'system' ? 'user' : message.role,
|
||||
content: textContent ? [{ type: 'input_text', text: textContent }] : []
|
||||
content: content ? [{ type: 'input_text', text: content }] : []
|
||||
} as OpenAI.Responses.EasyInputMessage
|
||||
}
|
||||
}
|
||||
|
||||
const parts: OpenAI.Responses.ResponseInputContent[] = []
|
||||
if (imageContents) {
|
||||
if (content) {
|
||||
parts.push({
|
||||
type: 'input_text',
|
||||
text: textContent
|
||||
text: content
|
||||
})
|
||||
}
|
||||
|
||||
if (imageContents.length > 0) {
|
||||
for (const imageContent of imageContents) {
|
||||
const image = await window.api.file.base64Image(imageContent.fileId + imageContent.fileExt)
|
||||
parts.push({
|
||||
detail: 'auto',
|
||||
type: 'input_image',
|
||||
image_url: image.data
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const imageBlock of imageBlocks) {
|
||||
if (isVision) {
|
||||
if (imageBlock.file) {
|
||||
|
||||
@@ -76,6 +76,17 @@ export function getAiSdkProviderId(provider: Provider): ProviderId | 'openai-com
|
||||
export async function createAiSdkProvider(config) {
|
||||
let localProvider: Awaited<AiSdkProvider> | null = null
|
||||
try {
|
||||
// 特殊处理 claude-code provider,通过 IPC 在主线程中创建
|
||||
// if (config.providerId === 'claude-code') {
|
||||
localProvider = await window.api.provider.createClaudeCode()
|
||||
logger.debug('Claude-code provider created via IPC', {
|
||||
providerId: config.providerId,
|
||||
hasOptions: !!config.options
|
||||
})
|
||||
console.log('localProvider', localProvider)
|
||||
return localProvider
|
||||
// }
|
||||
|
||||
if (config.providerId === 'openai' && config.options?.mode === 'chat') {
|
||||
config.providerId = `${config.providerId}-chat`
|
||||
} else if (config.providerId === 'azure' && config.options?.mode === 'responses') {
|
||||
|
||||
@@ -92,7 +92,6 @@ function formatProviderApiHost(provider: Provider): Provider {
|
||||
*/
|
||||
export function getActualProvider(model: Model): Provider {
|
||||
const baseProvider = getProviderByModel(model)
|
||||
|
||||
// 按顺序处理各种转换
|
||||
let actualProvider = cloneDeep(baseProvider)
|
||||
actualProvider = handleSpecialProviders(model, actualProvider)
|
||||
|
||||
@@ -102,8 +102,7 @@ Call this tool to execute the search. You can optionally provide additional cont
|
||||
content: ref.content,
|
||||
sourceUrl: ref.sourceUrl,
|
||||
type: ref.type,
|
||||
file: ref.file,
|
||||
metadata: ref.metadata
|
||||
file: ref.file
|
||||
}))
|
||||
|
||||
// const referenceContent = `\`\`\`json\n${JSON.stringify(knowledgeReferencesData, null, 2)}\n\`\`\``
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
@@ -13,7 +13,6 @@ import { Dropdown } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
@@ -31,7 +30,6 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
|
||||
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
|
||||
const { openedKeepAliveMinapps, currentMinappId, minappShow } = useRuntime()
|
||||
const dispatch = useDispatch()
|
||||
const navigate = useNavigate()
|
||||
const isPinned = pinned.some((p) => p.id === app.id)
|
||||
const isVisible = minapps.some((m) => m.id === app.id)
|
||||
const isActive = minappShow && currentMinappId === app.id
|
||||
@@ -39,13 +37,7 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
|
||||
const { isTopNavbar } = useNavbarPosition()
|
||||
|
||||
const handleClick = () => {
|
||||
if (isTopNavbar) {
|
||||
// 顶部导航栏:导航到小程序页面
|
||||
navigate(`/apps/${app.id}`)
|
||||
} else {
|
||||
// 侧边导航栏:保持原有弹窗行为
|
||||
openMinappKeepAlive(app)
|
||||
}
|
||||
openMinappKeepAlive(app)
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import WindowControls from '@renderer/components/WindowControls'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useBridge } from '@renderer/hooks/useBridge'
|
||||
@@ -25,7 +24,6 @@ import { useAppDispatch } from '@renderer/store'
|
||||
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { delay } from '@renderer/utils'
|
||||
import { clearWebviewState, getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
|
||||
import { Alert, Avatar, Button, Drawer, Tooltip } from 'antd'
|
||||
import { WebviewTag } from 'electron'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -164,7 +162,8 @@ const MinappPopupContainer: React.FC = () => {
|
||||
|
||||
/** store the webview refs, one of the key to make them keepalive */
|
||||
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
|
||||
/** Note: WebView loaded states now managed globally via webviewStateManager */
|
||||
/** indicate whether the webview has loaded */
|
||||
const webviewLoadedRefs = useRef<Map<string, boolean>>(new Map())
|
||||
/** whether the minapps open link external is enabled */
|
||||
const { minappsOpenLinkExternal } = useSettings()
|
||||
|
||||
@@ -186,7 +185,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
|
||||
setIsPopupShow(true)
|
||||
|
||||
if (getWebviewLoaded(currentMinappId)) {
|
||||
if (webviewLoadedRefs.current.get(currentMinappId)) {
|
||||
setIsReady(true)
|
||||
/** the case that open the minapp from sidebar */
|
||||
} else if (lastMinappId.current !== currentMinappId && lastMinappShow.current === minappShow) {
|
||||
@@ -217,21 +216,17 @@ const MinappPopupContainer: React.FC = () => {
|
||||
webviewRef.style.display = appid === currentMinappId ? 'inline-flex' : 'none'
|
||||
})
|
||||
|
||||
// Set external link behavior for current minapp
|
||||
if (currentMinappId) {
|
||||
const webviewElement = webviewRefs.current.get(currentMinappId)
|
||||
if (webviewElement) {
|
||||
try {
|
||||
const webviewId = webviewElement.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
|
||||
}
|
||||
} catch (error) {
|
||||
// WebView not ready yet, will be set when it's loaded
|
||||
logger.debug(`WebView ${currentMinappId} not ready for getWebContentsId()`)
|
||||
//delete the extra webviewLoadedRefs
|
||||
webviewLoadedRefs.current.forEach((_, appid) => {
|
||||
if (!webviewRefs.current.has(appid)) {
|
||||
webviewLoadedRefs.current.delete(appid)
|
||||
} else if (appid === currentMinappId) {
|
||||
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [currentMinappId, minappsOpenLinkExternal])
|
||||
|
||||
/** only the keepalive minapp can be minimized */
|
||||
@@ -260,17 +255,15 @@ const MinappPopupContainer: React.FC = () => {
|
||||
/** get the current app info with extra info */
|
||||
let currentAppInfo: AppInfo | null = null
|
||||
if (currentMinappId) {
|
||||
const currentApp = combinedApps.find((item) => item.id === currentMinappId)
|
||||
if (currentApp) {
|
||||
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
|
||||
}
|
||||
const currentApp = combinedApps.find((item) => item.id === currentMinappId) as MinAppType
|
||||
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
|
||||
}
|
||||
|
||||
/** will close the popup and delete the webview */
|
||||
const handlePopupClose = async (appid: string) => {
|
||||
setIsPopupShow(false)
|
||||
await delay(0.3)
|
||||
clearWebviewState(appid)
|
||||
webviewLoadedRefs.current.delete(appid)
|
||||
closeMinapp(appid)
|
||||
}
|
||||
|
||||
@@ -299,17 +292,10 @@ const MinappPopupContainer: React.FC = () => {
|
||||
|
||||
/** the callback function to set the webviews loaded indicator */
|
||||
const handleWebviewLoaded = (appid: string) => {
|
||||
setWebviewLoaded(appid, true)
|
||||
const webviewElement = webviewRefs.current.get(appid)
|
||||
if (webviewElement) {
|
||||
try {
|
||||
const webviewId = webviewElement.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`WebView ${appid} not ready for getWebContentsId() in handleWebviewLoaded`)
|
||||
}
|
||||
webviewLoadedRefs.current.set(appid, true)
|
||||
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
|
||||
if (webviewId) {
|
||||
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
|
||||
}
|
||||
if (appid == currentMinappId) {
|
||||
setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200)
|
||||
@@ -366,28 +352,16 @@ const MinappPopupContainer: React.FC = () => {
|
||||
/** navigate back in webview history */
|
||||
const handleGoBack = (appid: string) => {
|
||||
const webview = webviewRefs.current.get(appid)
|
||||
if (webview) {
|
||||
try {
|
||||
if (webview.canGoBack()) {
|
||||
webview.goBack()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`WebView ${appid} not ready for goBack()`)
|
||||
}
|
||||
if (webview && webview.canGoBack()) {
|
||||
webview.goBack()
|
||||
}
|
||||
}
|
||||
|
||||
/** navigate forward in webview history */
|
||||
const handleGoForward = (appid: string) => {
|
||||
const webview = webviewRefs.current.get(appid)
|
||||
if (webview) {
|
||||
try {
|
||||
if (webview.canGoForward()) {
|
||||
webview.goForward()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`WebView ${appid} not ready for goForward()`)
|
||||
}
|
||||
if (webview && webview.canGoForward()) {
|
||||
webview.goForward()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,10 +409,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
<Spacer />
|
||||
<ButtonsGroup
|
||||
className={isWin || isLinux ? 'windows' : ''}
|
||||
style={{ marginRight: isWin || isLinux ? '140px' : 0 }}
|
||||
isTopNavbar={isTopNavbar}>
|
||||
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''}>
|
||||
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<TitleButton onClick={() => handleGoBack(appInfo.id)}>
|
||||
<ArrowLeftOutlined />
|
||||
@@ -504,11 +475,6 @@ const MinappPopupContainer: React.FC = () => {
|
||||
</TitleButton>
|
||||
</Tooltip>
|
||||
</ButtonsGroup>
|
||||
{(isWin || isLinux) && (
|
||||
<div style={{ position: 'absolute', right: 0, top: 0, height: '100%' }}>
|
||||
<WindowControls />
|
||||
</div>
|
||||
)}
|
||||
</TitleContainer>
|
||||
)
|
||||
}
|
||||
@@ -532,25 +498,19 @@ const MinappPopupContainer: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={isTopNavbar ? null : <Title appInfo={currentAppInfo} url={currentUrl} />}
|
||||
title={<Title appInfo={currentAppInfo} url={currentUrl} />}
|
||||
placement="bottom"
|
||||
onClose={handlePopupMinimize}
|
||||
open={isPopupShow}
|
||||
mask={false}
|
||||
rootClassName="minapp-drawer"
|
||||
maskClassName="minapp-mask"
|
||||
height={isTopNavbar ? 'calc(100% - var(--navbar-height))' : '100%'}
|
||||
height={'100%'}
|
||||
maskClosable={false}
|
||||
closeIcon={null}
|
||||
styles={{
|
||||
wrapper: {
|
||||
position: 'fixed',
|
||||
marginLeft: isLeftNavbar ? 'var(--sidebar-width)' : 0,
|
||||
marginTop: isTopNavbar ? 'var(--navbar-height)' : 0
|
||||
},
|
||||
content: {
|
||||
backgroundColor: window.root.style.background
|
||||
}
|
||||
style={{
|
||||
marginLeft: isLeftNavbar ? 'var(--sidebar-width)' : 0,
|
||||
backgroundColor: window.root.style.background
|
||||
}}>
|
||||
{/* 在所有小程序中显示GoogleLoginTip */}
|
||||
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
|
||||
@@ -606,13 +566,14 @@ const TitleTextTooltip = styled.span`
|
||||
}
|
||||
`
|
||||
|
||||
const ButtonsGroup = styled.div<{ isTopNavbar: boolean }>`
|
||||
const ButtonsGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
-webkit-app-region: no-drag;
|
||||
&.windows {
|
||||
margin-right: ${isWin ? '130px' : isLinux ? '100px' : 0};
|
||||
background-color: var(--color-background-mute);
|
||||
border-radius: 50px;
|
||||
padding: 0 3px;
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import MinappPopupContainer from '@renderer/components/MinApp/MinappPopupContainer'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useNavbarPosition } from '@renderer/hooks/useSettings'
|
||||
|
||||
const TopViewMinappContainer = () => {
|
||||
const { openedKeepAliveMinapps, openedOneOffMinapp } = useRuntime()
|
||||
const { isLeftNavbar } = useNavbarPosition()
|
||||
const isCreate = openedKeepAliveMinapps.length > 0 || openedOneOffMinapp !== null
|
||||
|
||||
// Only show popup container in sidebar mode (left navbar), not in tab mode (top navbar)
|
||||
return <>{isCreate && isLeftNavbar && <MinappPopupContainer />}</>
|
||||
return <>{isCreate && <MinappPopupContainer />}</>
|
||||
}
|
||||
|
||||
export default TopViewMinappContainer
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { WebviewTag } from 'electron'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
const logger = loggerService.withContext('WebviewContainer')
|
||||
|
||||
/**
|
||||
* WebviewContainer is a component that renders a webview element.
|
||||
* It is used in the MinAppPopupContainer component.
|
||||
@@ -26,6 +23,7 @@ const WebviewContainer = memo(
|
||||
}) => {
|
||||
const webviewRef = useRef<WebviewTag | null>(null)
|
||||
const { enableSpellCheck } = useSettings()
|
||||
const { isLeftNavbar } = useNavbarPosition()
|
||||
|
||||
const setRef = (appid: string) => {
|
||||
onSetRefCallback(appid, null)
|
||||
@@ -43,29 +41,8 @@ const WebviewContainer = memo(
|
||||
useEffect(() => {
|
||||
if (!webviewRef.current) return
|
||||
|
||||
let loadCallbackFired = false
|
||||
|
||||
const handleLoaded = () => {
|
||||
logger.debug(`WebView did-finish-load for app: ${appid}`)
|
||||
// Only fire callback once per load cycle
|
||||
if (!loadCallbackFired) {
|
||||
loadCallbackFired = true
|
||||
// Small delay to ensure content is actually visible
|
||||
setTimeout(() => {
|
||||
logger.debug(`Calling onLoadedCallback for app: ${appid}`)
|
||||
onLoadedCallback(appid)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// Additional callback for when page is ready to show
|
||||
const handleReadyToShow = () => {
|
||||
logger.debug(`WebView ready-to-show for app: ${appid}`)
|
||||
if (!loadCallbackFired) {
|
||||
loadCallbackFired = true
|
||||
logger.debug(`Calling onLoadedCallback from ready-to-show for app: ${appid}`)
|
||||
onLoadedCallback(appid)
|
||||
}
|
||||
onLoadedCallback(appid)
|
||||
}
|
||||
|
||||
const handleNavigate = (event: any) => {
|
||||
@@ -79,25 +56,16 @@ const WebviewContainer = memo(
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartLoading = () => {
|
||||
// Reset callback flag when starting a new load
|
||||
loadCallbackFired = false
|
||||
}
|
||||
|
||||
webviewRef.current.addEventListener('did-start-loading', handleStartLoading)
|
||||
webviewRef.current.addEventListener('dom-ready', handleDomReady)
|
||||
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
|
||||
webviewRef.current.addEventListener('ready-to-show', handleReadyToShow)
|
||||
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
|
||||
|
||||
// we set the url when the webview is ready
|
||||
webviewRef.current.src = url
|
||||
|
||||
return () => {
|
||||
webviewRef.current?.removeEventListener('did-start-loading', handleStartLoading)
|
||||
webviewRef.current?.removeEventListener('dom-ready', handleDomReady)
|
||||
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
|
||||
webviewRef.current?.removeEventListener('ready-to-show', handleReadyToShow)
|
||||
webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate)
|
||||
}
|
||||
// because the appid and url are enough, no need to add onLoadedCallback
|
||||
@@ -105,8 +73,8 @@ const WebviewContainer = memo(
|
||||
}, [appid, url])
|
||||
|
||||
const WebviewStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
width: isLeftNavbar ? 'calc(100vw - var(--sidebar-width))' : '100vw',
|
||||
height: 'calc(100vh - var(--navbar-height))',
|
||||
backgroundColor: 'var(--color-background)',
|
||||
display: 'inline-flex'
|
||||
}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import { UploadOutlined } from '@ant-design/icons'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { FileMetadata } from '@renderer/types'
|
||||
import { mime2type, uuid } from '@renderer/utils'
|
||||
import { Modal, Space, Upload } from 'antd'
|
||||
import type { UploadFile } from 'antd/es/upload/interface'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TopView } from '../TopView'
|
||||
|
||||
const logger = loggerService.withContext('Video Popup')
|
||||
const { Dragger } = Upload
|
||||
|
||||
export interface VideoUploadResult {
|
||||
videoFile: FileMetadata
|
||||
srtFile: FileMetadata
|
||||
}
|
||||
|
||||
interface VideoPopupShowParams {
|
||||
title: string
|
||||
}
|
||||
|
||||
interface Props extends VideoPopupShowParams {
|
||||
resolve: (value: VideoUploadResult | null) => void
|
||||
}
|
||||
|
||||
type UploadType = 'video' | 'srt'
|
||||
|
||||
interface SingleFileUploaderProps {
|
||||
uploadType: UploadType
|
||||
accept: string
|
||||
title: string
|
||||
hint: string
|
||||
fileList: UploadFile[]
|
||||
onUpload: (file: File) => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
const SingleFileUploader: React.FC<SingleFileUploaderProps> = ({
|
||||
uploadType,
|
||||
accept,
|
||||
title,
|
||||
hint,
|
||||
fileList,
|
||||
onUpload,
|
||||
onRemove
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '8px', fontWeight: 'bold' }}>{title}</div>
|
||||
<Dragger
|
||||
name={uploadType}
|
||||
accept={accept}
|
||||
maxCount={1}
|
||||
fileList={fileList}
|
||||
customRequest={({ file }) => {
|
||||
if (file instanceof File) {
|
||||
onUpload(file)
|
||||
} else {
|
||||
logger.error('Upload failed: Invalid file format')
|
||||
}
|
||||
}}
|
||||
onRemove={onRemove}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">{t('knowledge.drag_file')}</p>
|
||||
<p className="ant-upload-hint">{hint}</p>
|
||||
</Dragger>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const VideoPopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [result, setResult] = useState<VideoUploadResult | null>(null)
|
||||
|
||||
const [videoFile, setVideoFile] = useState<FileMetadata | null>(null)
|
||||
const [srtFile, setSrtFile] = useState<FileMetadata | null>(null)
|
||||
|
||||
const [videoFileList, setVideoFileList] = useState<UploadFile[]>([])
|
||||
const [srtFileList, setSrtFileList] = useState<UploadFile[]>([])
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleFileUpload = async (
|
||||
file: File,
|
||||
uploadType: UploadType,
|
||||
setFile: (data: FileMetadata | null) => void,
|
||||
setFileList: (list: UploadFile[]) => void
|
||||
) => {
|
||||
const tempId = uuid()
|
||||
const tempFile: UploadFile = {
|
||||
uid: tempId,
|
||||
name: file.name,
|
||||
status: 'uploading'
|
||||
}
|
||||
setFileList([tempFile])
|
||||
|
||||
try {
|
||||
const newFileMetadata: FileMetadata = {
|
||||
id: uuid(),
|
||||
name: file.name,
|
||||
path: window.api.file.getPathForFile(file),
|
||||
size: file.size,
|
||||
ext: `.${file.name.split('.').pop()?.toLowerCase()}`,
|
||||
count: 1,
|
||||
origin_name: file.name,
|
||||
type: mime2type(file.type),
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
const uploadedFile = await FileManager.uploadFile(newFileMetadata)
|
||||
setFile(uploadedFile)
|
||||
|
||||
setFileList([{ ...tempFile, status: 'done', url: uploadedFile.path }])
|
||||
} catch (error) {
|
||||
logger.error(`Failed to upload ${uploadType} file: ${error}`)
|
||||
setFileList([{ ...tempFile, status: 'error', response: '上传失败' }])
|
||||
setFile(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileRemove = (
|
||||
setFile: (data: FileMetadata | null) => void,
|
||||
setFileList: (list: UploadFile[]) => void
|
||||
) => {
|
||||
setFile(null)
|
||||
setFileList([])
|
||||
return true
|
||||
}
|
||||
|
||||
const onOk = () => {
|
||||
if (videoFile && srtFile) {
|
||||
setResult({ videoFile, srtFile })
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setResult(null)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onAfterClose = () => {
|
||||
resolve(result)
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
|
||||
VideoPopup.hide = onCancel
|
||||
const isOkButtonDisabled = !videoFile || !srtFile
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onAfterClose}
|
||||
transitionName="animation-move-down"
|
||||
centered
|
||||
width={600}
|
||||
okButtonProps={{ disabled: isOkButtonDisabled }}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}>
|
||||
<Space direction="vertical" style={{ width: '100%', gap: '16px' }}>
|
||||
<SingleFileUploader
|
||||
uploadType="video"
|
||||
accept="video/*"
|
||||
title={t('knowledge.videos_file')}
|
||||
hint={t('knowledge.file_hint', { file_types: 'MP4, AVI, MKV, MOV' })}
|
||||
fileList={videoFileList}
|
||||
onUpload={(file) => handleFileUpload(file, 'video', setVideoFile, setVideoFileList)}
|
||||
onRemove={() => handleFileRemove(setVideoFile, setVideoFileList)}
|
||||
/>
|
||||
|
||||
<SingleFileUploader
|
||||
uploadType="srt"
|
||||
accept=".srt"
|
||||
title={t('knowledge.subtitle_file')}
|
||||
hint={t('knowledge.file_hint', { file_types: 'SRT' })}
|
||||
fileList={srtFileList}
|
||||
onUpload={(file) => handleFileUpload(file, 'srt', setSrtFile, setSrtFileList)}
|
||||
onRemove={() => handleFileRemove(setSrtFile, setSrtFileList)}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'VideoPopup'
|
||||
|
||||
export default class VideoPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show(props: VideoPopupShowParams) {
|
||||
return new Promise<VideoUploadResult | null>((resolve) => {
|
||||
TopView.show(<VideoPopupContainer {...props} resolve={resolve} />, TopViewKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { PoeLogo } from '@renderer/components/Icons'
|
||||
import { getProviderLogo } from '@renderer/config/providers'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { generateColorFromChar, getFirstCharacter, getForegroundColor } from '@renderer/utils'
|
||||
import { Avatar } from 'antd'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ProviderAvatarPrimitiveProps {
|
||||
providerId: string
|
||||
providerName: string
|
||||
logoSrc?: string
|
||||
size?: number
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
interface ProviderAvatarProps {
|
||||
provider: Provider
|
||||
customLogos?: Record<string, string>
|
||||
size?: number
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const ProviderSvgLogo = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-radius: 100%;
|
||||
|
||||
& > svg {
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
}
|
||||
`
|
||||
|
||||
const ProviderLogo = styled(Avatar)`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
export const ProviderAvatarPrimitive: React.FC<ProviderAvatarPrimitiveProps> = ({
|
||||
providerId,
|
||||
providerName,
|
||||
logoSrc,
|
||||
size,
|
||||
className,
|
||||
style
|
||||
}) => {
|
||||
if (providerId === 'poe') {
|
||||
return (
|
||||
<ProviderSvgLogo className={className} style={style}>
|
||||
<PoeLogo fontSize={size} />
|
||||
</ProviderSvgLogo>
|
||||
)
|
||||
}
|
||||
|
||||
if (logoSrc) {
|
||||
return (
|
||||
<ProviderLogo draggable="false" shape="circle" src={logoSrc} className={className} style={style} size={size} />
|
||||
)
|
||||
}
|
||||
|
||||
const backgroundColor = generateColorFromChar(providerName)
|
||||
const color = providerName ? getForegroundColor(backgroundColor) : 'white'
|
||||
|
||||
return (
|
||||
<ProviderLogo
|
||||
size={size}
|
||||
shape="circle"
|
||||
className={className}
|
||||
style={{
|
||||
backgroundColor,
|
||||
color,
|
||||
...style
|
||||
}}>
|
||||
{getFirstCharacter(providerName)}
|
||||
</ProviderLogo>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProviderAvatar: React.FC<ProviderAvatarProps> = ({
|
||||
provider,
|
||||
customLogos = {},
|
||||
className,
|
||||
style,
|
||||
size
|
||||
}) => {
|
||||
const systemLogoSrc = getProviderLogo(provider.id)
|
||||
if (systemLogoSrc) {
|
||||
return (
|
||||
<ProviderAvatarPrimitive
|
||||
size={size}
|
||||
providerId={provider.id}
|
||||
providerName={provider.name}
|
||||
logoSrc={systemLogoSrc}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const customLogo = customLogos[provider.id]
|
||||
if (customLogo) {
|
||||
if (customLogo === 'poe') {
|
||||
return (
|
||||
<ProviderAvatarPrimitive
|
||||
size={size}
|
||||
providerId="poe"
|
||||
providerName={provider.name}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProviderAvatarPrimitive
|
||||
providerId={provider.id}
|
||||
providerName={provider.name}
|
||||
logoSrc={customLogo}
|
||||
size={size}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProviderAvatarPrimitive
|
||||
providerId={provider.id}
|
||||
providerName={provider.name}
|
||||
size={size}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { ProviderAvatarPrimitive } from '@renderer/components/ProviderAvatar'
|
||||
import { PROVIDER_LOGO_MAP } from '@renderer/config/providers'
|
||||
import { getProviderLabel } from '@renderer/i18n/label'
|
||||
import { Input, Tooltip } from 'antd'
|
||||
@@ -49,10 +48,10 @@ const ProviderLogoPicker: FC<Props> = ({ onProviderClick }) => {
|
||||
/>
|
||||
</SearchContainer>
|
||||
<LogoGrid>
|
||||
{filteredProviders.map(({ id, name, logo }) => (
|
||||
{filteredProviders.map(({ id, logo, name }) => (
|
||||
<Tooltip key={id} title={name} placement="top" mouseLeaveDelay={0}>
|
||||
<LogoItem onClick={(e) => handleProviderClick(e, id)}>
|
||||
<ProviderAvatarPrimitive providerId={id} size={52} providerName={name} logoSrc={logo} />
|
||||
<img src={logo} alt={name} draggable={false} />
|
||||
</LogoItem>
|
||||
</Tooltip>
|
||||
))}
|
||||
@@ -87,12 +86,11 @@ const LogoGrid = styled.div`
|
||||
const LogoItem = styled.div`
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--color-background-soft);
|
||||
border: 0.5px solid var(--color-border);
|
||||
@@ -104,8 +102,8 @@ const LogoItem = styled.div`
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { TopNavbarOpenedMinappTabs } from '@renderer/components/app/PinnedMinapps'
|
||||
import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { getThemeModeLabel, getTitleLabel } from '@renderer/i18n/label'
|
||||
import tabsService from '@renderer/services/TabsService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
@@ -38,23 +37,11 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import MinAppIcon from '../Icons/MinAppIcon'
|
||||
import WindowControls from '../WindowControls'
|
||||
|
||||
interface TabsContainerProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const getTabIcon = (tabId: string, minapps: any[]): React.ReactNode | undefined => {
|
||||
// Check if it's a minapp tab (format: apps:appId)
|
||||
if (tabId.startsWith('apps:')) {
|
||||
const appId = tabId.replace('apps:', '')
|
||||
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
if (app) {
|
||||
return <MinAppIcon size={14} app={app} />
|
||||
}
|
||||
}
|
||||
|
||||
const getTabIcon = (tabId: string): React.ReactNode | undefined => {
|
||||
switch (tabId) {
|
||||
case 'home':
|
||||
return <Home size={14} />
|
||||
@@ -95,7 +82,6 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
const isFullscreen = useFullscreen()
|
||||
const { settedTheme, toggleTheme } = useTheme()
|
||||
const { hideMinappPopup } = useMinappPopup()
|
||||
const { minapps } = useMinapps()
|
||||
const { t } = useTranslation()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [canScroll, setCanScroll] = useState(false)
|
||||
@@ -103,23 +89,9 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
const getTabId = (path: string): string => {
|
||||
if (path === '/') return 'home'
|
||||
const segments = path.split('/')
|
||||
// Handle minapp paths: /apps/appId -> apps:appId
|
||||
if (segments[1] === 'apps' && segments[2]) {
|
||||
return `apps:${segments[2]}`
|
||||
}
|
||||
return segments[1] // 获取第一个路径段作为 id
|
||||
}
|
||||
|
||||
const getTabTitle = (tabId: string): string => {
|
||||
// Check if it's a minapp tab
|
||||
if (tabId.startsWith('apps:')) {
|
||||
const appId = tabId.replace('apps:', '')
|
||||
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
return app ? app.name : 'MinApp'
|
||||
}
|
||||
return getTitleLabel(tabId)
|
||||
}
|
||||
|
||||
const shouldCreateTab = (path: string) => {
|
||||
if (path === '/') return false
|
||||
if (path === '/settings') return false
|
||||
@@ -224,8 +196,8 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
renderItem={(tab) => (
|
||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
||||
<TabHeader>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps)}</TabIcon>}
|
||||
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
|
||||
<TabTitle>{getTitleLabel(tab.id)}</TabTitle>
|
||||
</TabHeader>
|
||||
{tab.id !== 'home' && (
|
||||
<CloseButton
|
||||
@@ -252,6 +224,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
</AddTabButton>
|
||||
</TabsArea>
|
||||
<RightButtonsContainer>
|
||||
<TopNavbarOpenedMinappTabs />
|
||||
<Tooltip
|
||||
title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)}
|
||||
mouseEnterDelay={0.8}
|
||||
@@ -269,7 +242,6 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
<SettingsButton onClick={handleSettingsClick} $active={activeTabId === 'settings'}>
|
||||
<Settings size={16} />
|
||||
</SettingsButton>
|
||||
<WindowControls />
|
||||
</RightButtonsContainer>
|
||||
</TabsBar>
|
||||
<TabContent>{children}</TabContent>
|
||||
@@ -290,7 +262,7 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '75px' : '15px')};
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : '0')};
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
|
||||
height: var(--navbar-height);
|
||||
position: relative;
|
||||
-webkit-app-region: drag;
|
||||
@@ -429,7 +401,6 @@ const RightButtonsContainer = styled.div`
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
padding-right: ${isMac ? '12px' : '0'};
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const WindowControlsContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
-webkit-app-region: no-drag;
|
||||
user-select: none;
|
||||
`
|
||||
|
||||
export const ControlButton = styled.button<{ $isClose?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: var(--navbar-height);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
border-radius: 0;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => (props.$isClose ? '#e81123' : 'rgba(128, 128, 128, 0.3)')};
|
||||
color: ${(props) => (props.$isClose ? '#ffffff' : 'var(--color-text)')};
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: ${(props) => (props.$isClose ? '#c50e1f' : 'rgba(128, 128, 128, 0.4)')};
|
||||
color: ${(props) => (props.$isClose ? '#ffffff' : 'var(--color-text)')};
|
||||
}
|
||||
|
||||
svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
`
|
||||
@@ -1,82 +0,0 @@
|
||||
import { isLinux, isWin } from '@renderer/config/constant'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Minus, Square, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ControlButton, WindowControlsContainer } from './WindowControls.styled'
|
||||
|
||||
// Custom restore icon - two overlapping squares like Windows
|
||||
const RestoreIcon: React.FC<{ size?: number }> = ({ size = 14 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1">
|
||||
{/* Back square (top-right) */}
|
||||
<path d="M 4 2 H 11 V 9 H 9 V 4 H 4 V 2" />
|
||||
{/* Front square (bottom-left) */}
|
||||
<rect x="2" y="4" width="7" height="7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const DEFAULT_DELAY = 1
|
||||
|
||||
const WindowControls: React.FC = () => {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
// Check initial maximized state
|
||||
window.api.windowControls.isMaximized().then(setIsMaximized)
|
||||
|
||||
// Listen for maximized state changes
|
||||
const unsubscribe = window.api.windowControls.onMaximizedChange(setIsMaximized)
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Only show on Windows and Linux
|
||||
if (!isWin && !isLinux) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
window.api.windowControls.minimize()
|
||||
}
|
||||
|
||||
const handleMaximize = () => {
|
||||
if (isMaximized) {
|
||||
window.api.windowControls.unmaximize()
|
||||
} else {
|
||||
window.api.windowControls.maximize()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
window.api.windowControls.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<WindowControlsContainer>
|
||||
<Tooltip title={t('navbar.window.minimize')} placement="bottom" mouseEnterDelay={DEFAULT_DELAY}>
|
||||
<ControlButton onClick={handleMinimize} aria-label="Minimize">
|
||||
<Minus size={14} />
|
||||
</ControlButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={isMaximized ? t('navbar.window.restore') : t('navbar.window.maximize')}
|
||||
placement="bottom"
|
||||
mouseEnterDelay={DEFAULT_DELAY}>
|
||||
<ControlButton onClick={handleMaximize} aria-label={isMaximized ? 'Restore' : 'Maximize'}>
|
||||
{isMaximized ? <RestoreIcon size={14} /> : <Square size={14} />}
|
||||
</ControlButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('navbar.window.close')} placement="bottom" mouseEnterDelay={DEFAULT_DELAY}>
|
||||
<ControlButton $isClose onClick={handleClose} aria-label="Close">
|
||||
<X size={17} />
|
||||
</ControlButton>
|
||||
</Tooltip>
|
||||
</WindowControlsContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default WindowControls
|
||||
@@ -6,8 +6,6 @@ import type { FC, PropsWithChildren } from 'react'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import WindowControls from '../WindowControls'
|
||||
|
||||
type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const Navbar: FC<Props> = ({ children, ...props }) => {
|
||||
@@ -30,17 +28,7 @@ export const NavbarLeft: FC<Props> = ({ children, ...props }) => {
|
||||
}
|
||||
|
||||
export const NavbarCenter: FC<Props> = ({ children, ...props }) => {
|
||||
return (
|
||||
<NavbarCenterContainer {...props}>
|
||||
{children}
|
||||
{/* Add WindowControls for Windows and Linux in NavbarCenter */}
|
||||
{(isWin || isLinux) && (
|
||||
<div style={{ position: 'absolute', right: 0, top: 0, height: '100%', display: 'flex', alignItems: 'center' }}>
|
||||
<WindowControls />
|
||||
</div>
|
||||
)}
|
||||
</NavbarCenterContainer>
|
||||
)
|
||||
return <NavbarCenterContainer {...props}>{children}</NavbarCenterContainer>
|
||||
}
|
||||
|
||||
export const NavbarRight: FC<Props> = ({ children, ...props }) => {
|
||||
@@ -93,7 +81,6 @@ const NavbarCenterContainer = styled.div`
|
||||
padding: 0 ${isMac ? '20px' : 0};
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
|
||||
@@ -6,13 +6,107 @@ import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { Dropdown, Tooltip } from 'antd'
|
||||
import { FC, useEffect } from 'react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { DraggableList } from '../DraggableList'
|
||||
import MinAppIcon from '../Icons/MinAppIcon'
|
||||
|
||||
/** Tabs of opened minapps in top navbar */
|
||||
export const TopNavbarOpenedMinappTabs: FC = () => {
|
||||
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
|
||||
const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup()
|
||||
const { showOpenedMinappsInSidebar } = useSettings()
|
||||
const { theme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
const [keepAliveMinapps, setKeepAliveMinapps] = useState(openedKeepAliveMinapps)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setKeepAliveMinapps(openedKeepAliveMinapps), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [openedKeepAliveMinapps])
|
||||
|
||||
// animation for minapp switch indicator
|
||||
useEffect(() => {
|
||||
const iconDefaultWidth = 30 // 22px icon + 8px gap
|
||||
const iconDefaultOffset = 10 // initial offset
|
||||
const container = document.querySelector('.TopNavContainer') as HTMLElement
|
||||
const activeIcon = document.querySelector('.TopNavContainer .opened-active') as HTMLElement
|
||||
|
||||
let indicatorLeft = 0,
|
||||
indicatorBottom = 0
|
||||
if (minappShow && activeIcon && container) {
|
||||
indicatorLeft = activeIcon.offsetLeft + activeIcon.offsetWidth / 2 - 4 // 4 is half of the indicator's width (8px)
|
||||
indicatorBottom = 0
|
||||
} else {
|
||||
indicatorLeft =
|
||||
((keepAliveMinapps.length > 0 ? keepAliveMinapps.length : 1) / 2) * iconDefaultWidth + iconDefaultOffset - 4
|
||||
indicatorBottom = -50
|
||||
}
|
||||
container?.style.setProperty('--indicator-left', `${indicatorLeft}px`)
|
||||
container?.style.setProperty('--indicator-bottom', `${indicatorBottom}px`)
|
||||
}, [currentMinappId, keepAliveMinapps, minappShow])
|
||||
|
||||
const handleOnClick = (app: MinAppType) => {
|
||||
if (minappShow && currentMinappId === app.id) {
|
||||
hideMinappPopup()
|
||||
} else {
|
||||
openMinappKeepAlive(app)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要显示已打开小程序组件
|
||||
const isShowOpened = showOpenedMinappsInSidebar && keepAliveMinapps.length > 0
|
||||
|
||||
// 如果不需要显示,返回空容器
|
||||
if (!isShowOpened) return null
|
||||
|
||||
return (
|
||||
<TopNavContainer
|
||||
className="TopNavContainer"
|
||||
style={{ backgroundColor: keepAliveMinapps.length > 0 ? 'var(--color-list-item)' : 'transparent' }}>
|
||||
<TopNavMenus>
|
||||
{keepAliveMinapps.map((app) => {
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'closeApp',
|
||||
label: t('minapp.sidebar.close.title'),
|
||||
onClick: () => {
|
||||
closeMinapp(app.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'closeAllApp',
|
||||
label: t('minapp.sidebar.closeall.title'),
|
||||
onClick: () => {
|
||||
closeAllMinapps()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const isActive = minappShow && currentMinappId === app.id
|
||||
|
||||
return (
|
||||
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
|
||||
<TopNavItemContainer
|
||||
onClick={() => handleOnClick(app)}
|
||||
theme={theme}
|
||||
className={`${isActive ? 'opened-active' : ''}`}>
|
||||
<TopNavIcon theme={theme}>
|
||||
<MinAppIcon size={22} app={app} style={{ border: 'none', padding: 0 }} />
|
||||
</TopNavIcon>
|
||||
</TopNavItemContainer>
|
||||
</Dropdown>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</TopNavMenus>
|
||||
</TopNavContainer>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tabs of opened minapps in sidebar */
|
||||
export const SidebarOpenedMinappTabs: FC = () => {
|
||||
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
|
||||
@@ -22,7 +116,7 @@ export const SidebarOpenedMinappTabs: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { isLeftNavbar } = useNavbarPosition()
|
||||
|
||||
const handleOnClick = (app: MinAppType) => {
|
||||
const handleOnClick = (app) => {
|
||||
if (minappShow && currentMinappId === app.id) {
|
||||
hideMinappPopup()
|
||||
} else {
|
||||
@@ -235,3 +329,50 @@ const TabsWrapper = styled.div`
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const TopNavContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
gap: 4px;
|
||||
background-color: var(--color-list-item);
|
||||
border-radius: 20px;
|
||||
margin: 0 5px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: var(--indicator-left, 0);
|
||||
bottom: var(--indicator-bottom, 0);
|
||||
width: 8px;
|
||||
height: 4px;
|
||||
background-color: var(--color-primary);
|
||||
transition:
|
||||
left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
bottom 0.3s ease-in-out;
|
||||
border-radius: 2px;
|
||||
}
|
||||
`
|
||||
|
||||
const TopNavMenus = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const TopNavIcon = styled(Icon)`
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
`
|
||||
|
||||
const TopNavItemContainer = styled.div`
|
||||
display: flex;
|
||||
transition: border 0.2s ease;
|
||||
border-radius: 18px;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
`
|
||||
|
||||
@@ -2385,11 +2385,8 @@ export function isGenerateImageModel(model: Model): boolean {
|
||||
|
||||
const modelId = getLowerBaseModelName(model.id, '/')
|
||||
|
||||
if (provider.type === 'openai-response') {
|
||||
return (
|
||||
OPENAI_IMAGE_GENERATION_MODELS.some((imageModel) => modelId.includes(imageModel)) ||
|
||||
GENERATE_IMAGE_MODELS.some((imageModel) => modelId.includes(imageModel))
|
||||
)
|
||||
if (provider && provider.type === 'openai-response') {
|
||||
return OPENAI_IMAGE_GENERATION_MODELS.some((imageModel) => modelId.includes(imageModel))
|
||||
}
|
||||
|
||||
return GENERATE_IMAGE_MODELS.some((imageModel) => modelId.includes(imageModel))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
BuiltinOcrProvider,
|
||||
BuiltinOcrProviderId,
|
||||
OcrPpocrProvider,
|
||||
OcrProviderCapability,
|
||||
OcrSystemProvider,
|
||||
OcrTesseractProvider,
|
||||
@@ -38,22 +37,9 @@ const systemOcr: OcrSystemProvider = {
|
||||
}
|
||||
} as const satisfies OcrSystemProvider
|
||||
|
||||
const ppocrOcr: OcrPpocrProvider = {
|
||||
id: 'paddleocr',
|
||||
name: 'PaddleOCR',
|
||||
config: {
|
||||
apiUrl: ''
|
||||
},
|
||||
capabilities: {
|
||||
image: true
|
||||
// pdf: true
|
||||
}
|
||||
} as const
|
||||
|
||||
export const BUILTIN_OCR_PROVIDERS_MAP = {
|
||||
tesseract,
|
||||
system: systemOcr,
|
||||
paddleocr: ppocrOcr
|
||||
system: systemOcr
|
||||
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
|
||||
|
||||
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)
|
||||
|
||||
@@ -661,7 +661,7 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
vertexai: VertexAIProviderLogo,
|
||||
'new-api': NewAPIProviderLogo,
|
||||
'aws-bedrock': AwsProviderLogo,
|
||||
poe: 'poe' // use svg icon component
|
||||
poe: 'svg' // use svg icon component
|
||||
} as const
|
||||
|
||||
export function getProviderLogo(providerId: string) {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
CustomTranslateLanguage,
|
||||
FileMetadata,
|
||||
KnowledgeNoteItem,
|
||||
QuickPhrase,
|
||||
TranslateHistory
|
||||
} from '@renderer/types'
|
||||
import { CustomTranslateLanguage, FileMetadata, KnowledgeItem, QuickPhrase, TranslateHistory } from '@renderer/types'
|
||||
// Import necessary types for blocks and new message structure
|
||||
import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { NotesTreeNode } from '@renderer/types/note'
|
||||
@@ -19,7 +13,7 @@ export const db = new Dexie('CherryStudio', {
|
||||
files: EntityTable<FileMetadata, 'id'>
|
||||
topics: EntityTable<{ id: string; messages: NewMessage[] }, 'id'> // Correct type for topics
|
||||
settings: EntityTable<{ id: string; value: any }, 'id'>
|
||||
knowledge_notes: EntityTable<KnowledgeNoteItem, 'id'>
|
||||
knowledge_notes: EntityTable<KnowledgeItem, 'id'>
|
||||
translate_history: EntityTable<TranslateHistory, 'id'>
|
||||
quick_phrases: EntityTable<QuickPhrase, 'id'>
|
||||
message_blocks: EntityTable<MessageBlock, 'id'> // Correct type for message_blocks
|
||||
|
||||
@@ -15,32 +15,21 @@ import {
|
||||
updateItemProcessingStatus,
|
||||
updateNotes
|
||||
} from '@renderer/store/knowledge'
|
||||
import { addFilesThunk, addItemThunk, addNoteThunk, addVedioThunk } from '@renderer/store/thunk/knowledgeThunk'
|
||||
import {
|
||||
FileMetadata,
|
||||
isKnowledgeFileItem,
|
||||
isKnowledgeNoteItem,
|
||||
isKnowledgeVideoItem,
|
||||
KnowledgeBase,
|
||||
KnowledgeItem,
|
||||
KnowledgeNoteItem,
|
||||
MigrationModeEnum,
|
||||
ProcessingStatus
|
||||
} from '@renderer/types'
|
||||
import { runAsyncFunction, uuid } from '@renderer/utils'
|
||||
import { addFilesThunk, addItemThunk, addNoteThunk } from '@renderer/store/thunk/knowledgeThunk'
|
||||
import { FileMetadata, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
|
||||
import { useAgents } from './useAgents'
|
||||
import { useAssistants } from './useAssistant'
|
||||
import { useTimer } from './useTimer'
|
||||
|
||||
export const useKnowledge = (baseId: string) => {
|
||||
const dispatch = useAppDispatch()
|
||||
const base = useSelector((state: RootState) => state.knowledge.bases.find((b) => b.id === baseId))
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
const checkTimerRef = useRef<NodeJS.Timeout>(undefined)
|
||||
|
||||
// 重命名知识库
|
||||
const renameKnowledgeBase = (name: string) => {
|
||||
@@ -52,11 +41,16 @@ export const useKnowledge = (baseId: string) => {
|
||||
dispatch(updateBase(base))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(checkTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 检查知识库
|
||||
const checkAllBases = () => {
|
||||
// 这个也许也会多任务?
|
||||
const id = uuid()
|
||||
setTimeoutTimer(id, () => KnowledgeQueue.checkAllBases(), 0)
|
||||
clearTimeout(checkTimerRef.current)
|
||||
checkTimerRef.current = setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
||||
}
|
||||
|
||||
// 批量添加文件
|
||||
@@ -88,13 +82,6 @@ export const useKnowledge = (baseId: string) => {
|
||||
dispatch(addItemThunk(baseId, 'directory', path))
|
||||
checkAllBases()
|
||||
}
|
||||
|
||||
// add video support
|
||||
const addVideo = (files: FileMetadata[]) => {
|
||||
dispatch(addVedioThunk(baseId, 'video', files))
|
||||
checkAllBases()
|
||||
}
|
||||
|
||||
// 更新笔记内容
|
||||
const updateNoteContent = async (noteId: string, content: string) => {
|
||||
const note = await db.knowledge_notes.get(noteId)
|
||||
@@ -123,28 +110,18 @@ export const useKnowledge = (baseId: string) => {
|
||||
// 移除项目
|
||||
const removeItem = async (item: KnowledgeItem) => {
|
||||
dispatch(removeItemAction({ baseId, item }))
|
||||
if (!base || !item?.uniqueId || !item?.uniqueIds) {
|
||||
return
|
||||
if (base) {
|
||||
if (item?.uniqueId && item?.uniqueIds) {
|
||||
await window.api.knowledgeBase.remove({
|
||||
uniqueId: item.uniqueId,
|
||||
uniqueIds: item.uniqueIds,
|
||||
base: getKnowledgeBaseParams(base)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const removalParams = {
|
||||
uniqueId: item.uniqueId,
|
||||
uniqueIds: item.uniqueIds,
|
||||
base: getKnowledgeBaseParams(base)
|
||||
}
|
||||
|
||||
await window.api.knowledgeBase.remove(removalParams)
|
||||
|
||||
if (isKnowledgeFileItem(item) && typeof item.content === 'object' && !Array.isArray(item.content)) {
|
||||
const file = item.content
|
||||
if (item.type === 'file' && typeof item.content === 'object') {
|
||||
// name: eg. text.pdf
|
||||
await window.api.file.delete(file.name)
|
||||
} else if (isKnowledgeVideoItem(item)) {
|
||||
// video item has srt and video files
|
||||
const files = item.content
|
||||
const deletePromises = files.map((file) => window.api.file.delete(file.name))
|
||||
|
||||
await Promise.allSettled(deletePromises)
|
||||
await window.api.file.delete(item.content.name)
|
||||
}
|
||||
}
|
||||
// 刷新项目
|
||||
@@ -155,9 +132,6 @@ export const useKnowledge = (baseId: string) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!base || !item?.uniqueId || !item?.uniqueIds) {
|
||||
return
|
||||
}
|
||||
if (base && item.uniqueId && item.uniqueIds) {
|
||||
await window.api.knowledgeBase.remove({
|
||||
uniqueId: item.uniqueId,
|
||||
@@ -174,24 +148,6 @@ export const useKnowledge = (baseId: string) => {
|
||||
})
|
||||
checkAllBases()
|
||||
}
|
||||
|
||||
const removalParams = {
|
||||
uniqueId: item.uniqueId,
|
||||
uniqueIds: item.uniqueIds,
|
||||
base: getKnowledgeBaseParams(base)
|
||||
}
|
||||
|
||||
await window.api.knowledgeBase.remove(removalParams)
|
||||
|
||||
updateItem({
|
||||
...item,
|
||||
processingStatus: 'pending',
|
||||
processingProgress: 0,
|
||||
processingError: '',
|
||||
uniqueId: undefined,
|
||||
updated_at: Date.now()
|
||||
})
|
||||
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
||||
}
|
||||
|
||||
// 更新处理状态
|
||||
@@ -231,7 +187,7 @@ export const useKnowledge = (baseId: string) => {
|
||||
}
|
||||
|
||||
// 迁移知识库(保留原知识库)
|
||||
const migrateBase = async (newBase: KnowledgeBase, mode: MigrationModeEnum) => {
|
||||
const migrateBase = async (newBase: KnowledgeBase) => {
|
||||
if (!base) return
|
||||
|
||||
const timestamp = dayjs().format('YYMMDDHHmmss')
|
||||
@@ -244,13 +200,8 @@ export const useKnowledge = (baseId: string) => {
|
||||
name: newName,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
items: [],
|
||||
framework: mode === MigrationModeEnum.MigrationToLangChain ? 'langchain' : base.framework
|
||||
} satisfies KnowledgeBase
|
||||
|
||||
if (mode === MigrationModeEnum.MigrationToLangChain) {
|
||||
await window.api.knowledgeBase.create(getKnowledgeBaseParams(migratedBase))
|
||||
}
|
||||
items: []
|
||||
} as KnowledgeBase
|
||||
|
||||
dispatch(addBase(migratedBase))
|
||||
|
||||
@@ -261,27 +212,23 @@ export const useKnowledge = (baseId: string) => {
|
||||
switch (item.type) {
|
||||
case 'file':
|
||||
if (typeof item.content === 'object' && item.content !== null && 'path' in item.content) {
|
||||
files.push(item.content)
|
||||
files.push(item.content as FileMetadata)
|
||||
}
|
||||
break
|
||||
case 'note':
|
||||
try {
|
||||
const note = await db.knowledge_notes.get(item.id)
|
||||
const content = note?.content || ''
|
||||
const content = (note?.content || '') as string
|
||||
await dispatch(addNoteThunk(newBase.id, content))
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to migrate note item ${item.id}: ${error}`)
|
||||
}
|
||||
break
|
||||
default:
|
||||
if (typeof item.content === 'string') {
|
||||
try {
|
||||
dispatch(addItemThunk(newBase.id, item.type, item.content))
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to migrate item ${item.id}: ${error}`)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Not a valid item: ${JSON.stringify(item)}`)
|
||||
try {
|
||||
dispatch(addItemThunk(newBase.id, item.type, item.content as string))
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to migrate item ${item.id}: ${error}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -303,18 +250,17 @@ export const useKnowledge = (baseId: string) => {
|
||||
const urlItems = base?.items.filter((item) => item.type === 'url') || []
|
||||
const sitemapItems = base?.items.filter((item) => item.type === 'sitemap') || []
|
||||
const [noteItems, setNoteItems] = useState<KnowledgeItem[]>([])
|
||||
const videoItems = base?.items.filter((item) => item.type === 'video') || []
|
||||
|
||||
useEffect(() => {
|
||||
const notes = base?.items.filter(isKnowledgeNoteItem) ?? []
|
||||
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 ?? '' } satisfies KnowledgeNoteItem
|
||||
return { ...item, content: note?.content || '' }
|
||||
})
|
||||
)
|
||||
setNoteItems(newNoteItems)
|
||||
setNoteItems(newNoteItems.filter((note) => note !== undefined) as KnowledgeItem[])
|
||||
})
|
||||
}, [base?.items])
|
||||
|
||||
@@ -324,7 +270,6 @@ export const useKnowledge = (baseId: string) => {
|
||||
urlItems,
|
||||
sitemapItems,
|
||||
noteItems,
|
||||
videoItems,
|
||||
renameKnowledgeBase,
|
||||
updateKnowledgeBase,
|
||||
migrateBase,
|
||||
@@ -332,7 +277,6 @@ export const useKnowledge = (baseId: string) => {
|
||||
addUrl,
|
||||
addSitemap,
|
||||
addNote,
|
||||
addVideo,
|
||||
updateNoteContent,
|
||||
getNoteContent,
|
||||
updateItem,
|
||||
@@ -363,9 +307,7 @@ export const useKnowledgeBases = () => {
|
||||
}
|
||||
|
||||
const deleteKnowledgeBase = (baseId: string) => {
|
||||
const base = bases.find((b) => b.id === baseId)
|
||||
if (!base) return
|
||||
dispatch(deleteBase({ baseId, baseParams: getKnowledgeBaseParams(base) }))
|
||||
dispatch(deleteBase({ baseId }))
|
||||
|
||||
// remove assistant knowledge_base
|
||||
const _assistants = assistants.map((assistant) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { KnowledgeBase } from '@renderer/types'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const createInitialKnowledgeBase = (): KnowledgeBase => ({
|
||||
@@ -14,11 +14,7 @@ const createInitialKnowledgeBase = (): KnowledgeBase => ({
|
||||
items: [],
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
version: 1,
|
||||
framework: 'langchain',
|
||||
retriever: {
|
||||
mode: 'hybrid'
|
||||
}
|
||||
version: 1
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -45,12 +41,6 @@ export const useKnowledgeBaseForm = (base?: KnowledgeBase) => {
|
||||
const { providers } = useProviders()
|
||||
const { preprocessProviders } = usePreprocessProviders()
|
||||
|
||||
useEffect(() => {
|
||||
if (base) {
|
||||
setNewBase(base)
|
||||
}
|
||||
}, [base])
|
||||
|
||||
const selectedDocPreprocessProvider = useMemo(
|
||||
() => newBase.preprocessProvider?.provider,
|
||||
[newBase.preprocessProvider]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
|
||||
import TabsService from '@renderer/services/TabsService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setCurrentMinappId,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
setOpenedOneOffMinapp
|
||||
} from '@renderer/store/runtime'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { clearWebviewState } from '@renderer/utils/webviewStateManager'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
@@ -38,18 +36,7 @@ export const useMinappPopup = () => {
|
||||
const createLRUCache = useCallback(() => {
|
||||
return new LRUCache<string, MinAppType>({
|
||||
max: maxKeepAliveMinapps,
|
||||
disposeAfter: (_value, key) => {
|
||||
// Clean up WebView state when app is disposed from cache
|
||||
clearWebviewState(key)
|
||||
|
||||
// Close corresponding tab if it exists
|
||||
const tabs = TabsService.getTabs()
|
||||
const tabToClose = tabs.find((tab) => tab.path === `/apps/${key}`)
|
||||
if (tabToClose) {
|
||||
TabsService.closeTab(tabToClose.id)
|
||||
}
|
||||
|
||||
// Update Redux state
|
||||
disposeAfter: () => {
|
||||
dispatch(setOpenedKeepAliveMinapps(Array.from(minAppsCache.values())))
|
||||
},
|
||||
onInsert: () => {
|
||||
@@ -171,8 +158,6 @@ export const useMinappPopup = () => {
|
||||
openMinappById,
|
||||
closeMinapp,
|
||||
hideMinappPopup,
|
||||
closeAllMinapps,
|
||||
// Expose cache instance for TabsService integration
|
||||
minAppsCache
|
||||
closeAllMinapps
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { loggerService } from '@logger'
|
||||
import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png'
|
||||
import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png'
|
||||
import { BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr'
|
||||
import { getBuiltinOcrProviderLabel } from '@renderer/i18n/label'
|
||||
@@ -81,8 +80,6 @@ export const useOcrProviders = () => {
|
||||
return <Avatar size={size} src={TesseractLogo} />
|
||||
case 'system':
|
||||
return <MonitorIcon size={size} />
|
||||
case 'paddleocr':
|
||||
return <Avatar size={size} src={PaddleocrLogo} />
|
||||
}
|
||||
}
|
||||
return <FileQuestionMarkIcon size={size} />
|
||||
|
||||
@@ -116,7 +116,7 @@ export function useMessageStyle() {
|
||||
}
|
||||
}
|
||||
|
||||
export const getStoreSetting = <K extends keyof SettingsState>(key: K): SettingsState[K] => {
|
||||
export const getStoreSetting = (key: keyof SettingsState) => {
|
||||
return store.getState().settings[key]
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export const useTimer = () => {
|
||||
|
||||
// 组件卸载时自动清理所有定时器
|
||||
useEffect(() => {
|
||||
return () => clearAllTimers()
|
||||
return clearAllTimers
|
||||
}, [])
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,15 +5,15 @@ import { initReactI18next } from 'react-i18next'
|
||||
|
||||
// Original translation
|
||||
import enUS from './locales/en-us.json'
|
||||
import jaJP from './locales/ja-jp.json'
|
||||
import ruRU from './locales/ru-ru.json'
|
||||
import zhCN from './locales/zh-cn.json'
|
||||
import zhTW from './locales/zh-tw.json'
|
||||
// Machine translation
|
||||
import elGR from './translate/el-gr.json'
|
||||
import esES from './translate/es-es.json'
|
||||
import frFR from './translate/fr-fr.json'
|
||||
import jaJP from './translate/ja-jp.json'
|
||||
import ptPT from './translate/pt-pt.json'
|
||||
import ruRU from './translate/ru-ru.json'
|
||||
|
||||
const logger = loggerService.withContext('I18N')
|
||||
|
||||
|
||||
@@ -327,12 +327,10 @@ export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
|
||||
|
||||
const builtinOcrProviderKeyMap = {
|
||||
system: 'ocr.builtin.system',
|
||||
tesseract: '',
|
||||
paddleocr: ''
|
||||
tesseract: ''
|
||||
} as const satisfies Record<BuiltinOcrProviderId, string>
|
||||
|
||||
export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => {
|
||||
if (key === 'tesseract') return 'Tesseract'
|
||||
else if (key == 'paddleocr') return 'PaddleOCR'
|
||||
else return getLabel(builtinOcrProviderKeyMap, key)
|
||||
}
|
||||
|
||||
@@ -963,11 +963,9 @@
|
||||
},
|
||||
"add_directory": "Add Directory",
|
||||
"add_file": "Add File",
|
||||
"add_image": "Add Image",
|
||||
"add_note": "Add Note",
|
||||
"add_sitemap": "Website Map",
|
||||
"add_url": "Add URL",
|
||||
"add_video": "Add video",
|
||||
"cancel_index": "Cancel Indexing",
|
||||
"chunk_overlap": "Chunk Overlap",
|
||||
"chunk_overlap_placeholder": "Default (not recommended to change)",
|
||||
@@ -994,7 +992,6 @@
|
||||
"document_count_default": "Default",
|
||||
"document_count_help": "The more document chunks requested, the more information is included, but the more tokens are consumed",
|
||||
"drag_file": "Drag file here",
|
||||
"drag_image": "Drag image here",
|
||||
"edit_remark": "Edit Remark",
|
||||
"edit_remark_placeholder": "Please enter remark content",
|
||||
"embedding_model": "Embedding Model",
|
||||
@@ -1003,15 +1000,9 @@
|
||||
"error": {
|
||||
"failed_to_create": "Knowledge base creation failed",
|
||||
"failed_to_edit": "Knowledge base editing failed",
|
||||
"model_invalid": "No model selected",
|
||||
"video": {
|
||||
"local_file_missing": "Video file not found",
|
||||
"youtube_url_missing": "YouTube video URL not found"
|
||||
}
|
||||
"model_invalid": "No model selected"
|
||||
},
|
||||
"file_hint": "Support {{file_types}}",
|
||||
"image_hint": "Support {{image_types}}",
|
||||
"images": "Images",
|
||||
"index_all": "Index All",
|
||||
"index_cancelled": "Indexing cancelled",
|
||||
"index_started": "Indexing started",
|
||||
@@ -1028,10 +1019,6 @@
|
||||
"error": {
|
||||
"failed": "Migration failed"
|
||||
},
|
||||
"migrate_to_langchain": {
|
||||
"content": "The knowledge base migration does not delete the old knowledge base but creates a copy and reprocesses all entries, which may consume a significant number of tokens. Please proceed with caution.",
|
||||
"info": "The knowledge base architecture has been updated. Click to migrate to the new architecture."
|
||||
},
|
||||
"source_dimensions": "Source Dimensions",
|
||||
"source_model": "Source Model",
|
||||
"target_dimensions": "Target Dimensions",
|
||||
@@ -1050,20 +1037,6 @@
|
||||
"quota": "{{name}} Left Quota: {{quota}}",
|
||||
"quota_infinity": "{{name}} Quota: Unlimited",
|
||||
"rename": "Rename",
|
||||
"retriever": "Retrieve mode",
|
||||
"retriever_bm25": "full-text search",
|
||||
"retriever_bm25_desc": "Search for documents based on keyword relevance and frequency.",
|
||||
"retriever_hybrid": "Hybrid Search (Recommended)",
|
||||
"retriever_hybrid_desc": "Combine keyword search and semantic search to achieve optimal retrieval accuracy.",
|
||||
"retriever_hybrid_weight": {
|
||||
"bm25": "full text",
|
||||
"recommended": "recommend",
|
||||
"title": "Hybrid Search Weight Adjustment (Full-text/Vector)",
|
||||
"vector": "vector"
|
||||
},
|
||||
"retriever_tooltip": "Using different retrieval methods to search the knowledge base",
|
||||
"retriever_vector": "vector search",
|
||||
"retriever_vector_desc": "Retrieve documents based on semantic similarity and meaning.",
|
||||
"search": "Search knowledge base",
|
||||
"search_placeholder": "Enter text to search",
|
||||
"settings": {
|
||||
@@ -1085,7 +1058,6 @@
|
||||
"status_preprocess_completed": "Preprocessing Completed",
|
||||
"status_preprocess_failed": "Preprocessing Failed",
|
||||
"status_processing": "Processing",
|
||||
"subtitle_file": "subtitle file",
|
||||
"threshold": "Matching threshold",
|
||||
"threshold_placeholder": "Not set",
|
||||
"threshold_too_large_or_small": "Threshold cannot be greater than 1 or less than 0",
|
||||
@@ -1097,9 +1069,7 @@
|
||||
"topN_tooltip": "The number of matching results returned; the larger the value, the more matching results, but also the more tokens consumed.",
|
||||
"url_added": "URL added",
|
||||
"url_placeholder": "Enter URL, multiple URLs separated by Enter",
|
||||
"urls": "URLs",
|
||||
"videos": "video",
|
||||
"videos_file": "video file"
|
||||
"urls": "URLs"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "Arabic",
|
||||
@@ -1406,13 +1376,6 @@
|
||||
"bubble": "Bubble",
|
||||
"label": "Message style",
|
||||
"plain": "Plain"
|
||||
},
|
||||
"video": {
|
||||
"error": {
|
||||
"local_file_missing": "Local video file path not found",
|
||||
"unsupported_type": "Unsupported video type",
|
||||
"youtube_url_missing": "YouTube video URL not found"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "Processing...",
|
||||
@@ -1653,13 +1616,7 @@
|
||||
"navbar": {
|
||||
"expand": "Expand Dialog",
|
||||
"hide_sidebar": "Hide Sidebar",
|
||||
"show_sidebar": "Show Sidebar",
|
||||
"window": {
|
||||
"close": "Close",
|
||||
"maximize": "Maximize",
|
||||
"minimize": "Minimize",
|
||||
"restore": "Restore"
|
||||
}
|
||||
"show_sidebar": "Show Sidebar"
|
||||
},
|
||||
"navigate": {
|
||||
"provider_settings": "Go to provider settings"
|
||||
@@ -3927,13 +3884,6 @@
|
||||
"title": "Image"
|
||||
},
|
||||
"image_provider": "OCR service provider",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Access token of AI Studio Community",
|
||||
"aistudio_url_label": "AI Studio Community",
|
||||
"api_url": "API URL",
|
||||
"serving_doc_url_label": "PaddleOCR Serving Documentation",
|
||||
"tip": "You can refer to the official PaddleOCR documentation to deploy a local service, or deploy a cloud service on the PaddlePaddle AI Studio Community. For the latter case, please provide the access token of the AI Studio Community."
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
"langs_tooltip": "Dependent on Windows to provide services, you need to download language packs in the system to support the relevant languages."
|
||||
|
||||
@@ -963,11 +963,9 @@
|
||||
},
|
||||
"add_directory": "ディレクトリを追加",
|
||||
"add_file": "ファイルを追加",
|
||||
"add_image": "画像を追加",
|
||||
"add_note": "ノートを追加",
|
||||
"add_sitemap": "サイトマップを追加",
|
||||
"add_url": "URLを追加",
|
||||
"add_video": "動画を追加",
|
||||
"cancel_index": "インデックスをキャンセル",
|
||||
"chunk_overlap": "チャンクの重なり",
|
||||
"chunk_overlap_placeholder": "デフォルト(変更しないでください)",
|
||||
@@ -994,7 +992,6 @@
|
||||
"document_count_default": "デフォルト",
|
||||
"document_count_help": "要求されたドキュメント分段数が多いほど、付随する情報が多くなりますが、トークンの消費量も増加します",
|
||||
"drag_file": "ファイルをここにドラッグ",
|
||||
"drag_image": "画像をここにドラッグ",
|
||||
"edit_remark": "備考を編集",
|
||||
"edit_remark_placeholder": "備考内容を入力してください",
|
||||
"embedding_model": "埋め込みモデル",
|
||||
@@ -1003,15 +1000,9 @@
|
||||
"error": {
|
||||
"failed_to_create": "ナレッジベースの作成に失敗しました",
|
||||
"failed_to_edit": "ナレッジベースの編集に失敗しました",
|
||||
"model_invalid": "モデルが選択されていません",
|
||||
"video": {
|
||||
"local_file_missing": "動画ファイルが見つかりません",
|
||||
"youtube_url_missing": "YouTube動画のURLが見つかりません"
|
||||
}
|
||||
"model_invalid": "モデルが選択されていません"
|
||||
},
|
||||
"file_hint": "{{file_types}} 形式をサポート",
|
||||
"image_hint": "{{image_types}} 形式に対応しています",
|
||||
"images": "画像",
|
||||
"index_all": "すべてをインデックス",
|
||||
"index_cancelled": "インデックスがキャンセルされました",
|
||||
"index_started": "インデックスを開始",
|
||||
@@ -1028,10 +1019,6 @@
|
||||
"error": {
|
||||
"failed": "移行が失敗しました"
|
||||
},
|
||||
"migrate_to_langchain": {
|
||||
"content": "ナレッジベースの移行は旧ナレッジベースを削除せず、すべてのエントリーを再処理したコピーを作成します。大量のトークンを消費する可能性があるため、操作には十分注意してください。",
|
||||
"info": "ナレッジベースのアーキテクチャが更新されました、新しいアーキテクチャに移行するにはクリックしてください"
|
||||
},
|
||||
"source_dimensions": "ソース次元",
|
||||
"source_model": "ソースモデル",
|
||||
"target_dimensions": "ターゲット次元",
|
||||
@@ -1050,20 +1037,6 @@
|
||||
"quota": "{{name}} 残りクォータ: {{quota}}",
|
||||
"quota_infinity": "{{name}} クォータ: 無制限",
|
||||
"rename": "名前を変更",
|
||||
"retriever": "検索モード",
|
||||
"retriever_bm25": "全文検索",
|
||||
"retriever_bm25_desc": "キーワードの関連性と頻度に基づいてドキュメントを検索します。",
|
||||
"retriever_hybrid": "ハイブリッド検索(おすすめ)",
|
||||
"retriever_hybrid_desc": "キーワード検索と意味検索を組み合わせて、最高の検索精度を実現します。",
|
||||
"retriever_hybrid_weight": {
|
||||
"bm25": "全文(ぜんぶん)",
|
||||
"recommended": "おすすめ",
|
||||
"title": "ハイブリッド検索の重み付け調整 (全文/ベクトル)",
|
||||
"vector": "ベクトル"
|
||||
},
|
||||
"retriever_tooltip": "異なる検索方法を使用してナレッジベースを検索する",
|
||||
"retriever_vector": "ベクトル検索",
|
||||
"retriever_vector_desc": "意味的な類似性と意味に基づいて文書を検索します。",
|
||||
"search": "ナレッジベースを検索",
|
||||
"search_placeholder": "検索するテキストを入力",
|
||||
"settings": {
|
||||
@@ -1085,7 +1058,6 @@
|
||||
"status_preprocess_completed": "前処理完了",
|
||||
"status_preprocess_failed": "前処理に失敗しました",
|
||||
"status_processing": "処理中",
|
||||
"subtitle_file": "字幕ファイル",
|
||||
"threshold": "マッチング度閾値",
|
||||
"threshold_placeholder": "未設置",
|
||||
"threshold_too_large_or_small": "しきい値は0より大きく1より小さい必要があります",
|
||||
@@ -1097,9 +1069,7 @@
|
||||
"topN_tooltip": "返されるマッチ結果の数は、数値が大きいほどマッチ結果が多くなりますが、消費されるトークンも増えます。",
|
||||
"url_added": "URLが追加されました",
|
||||
"url_placeholder": "URLを入力, 複数のURLはEnterで区切る",
|
||||
"urls": "URL",
|
||||
"videos": "動画",
|
||||
"videos_file": "動画ファイル"
|
||||
"urls": "URL"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "アラビア語",
|
||||
@@ -1406,13 +1376,6 @@
|
||||
"bubble": "バブル",
|
||||
"label": "メッセージスタイル",
|
||||
"plain": "プレーン"
|
||||
},
|
||||
"video": {
|
||||
"error": {
|
||||
"local_file_missing": "ローカル動画ファイルのパスが見つかりません",
|
||||
"unsupported_type": "サポートされていない動画タイプです",
|
||||
"youtube_url_missing": "YouTube動画のURLが見つかりません"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "処理中...",
|
||||
@@ -1653,13 +1616,7 @@
|
||||
"navbar": {
|
||||
"expand": "ダイアログを展開",
|
||||
"hide_sidebar": "サイドバーを非表示",
|
||||
"show_sidebar": "サイドバーを表示",
|
||||
"window": {
|
||||
"close": "閉じる",
|
||||
"maximize": "最大化",
|
||||
"minimize": "最小化",
|
||||
"restore": "元に戻す"
|
||||
}
|
||||
"show_sidebar": "サイドバーを表示"
|
||||
},
|
||||
"navigate": {
|
||||
"provider_settings": "プロバイダー設定に移動"
|
||||
@@ -3927,13 +3884,6 @@
|
||||
"title": "画像"
|
||||
},
|
||||
"image_provider": "OCRサービスプロバイダー",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "AI Studio Community のアクセス・トークン",
|
||||
"aistudio_url_label": "AI Studio Community",
|
||||
"api_url": "API URL",
|
||||
"serving_doc_url_label": "PaddleOCR サービング ドキュメント",
|
||||
"tip": "ローカルサービスをデプロイするには、公式の PaddleOCR ドキュメントを参照するか、PaddlePaddle AI Studio コミュニティ上でクラウドサービスをデプロイすることができます。後者の場合は、AI Studio コミュニティのアクセストークンを提供してください。"
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
"langs_tooltip": "Windows が提供するサービスに依存しており、関連する言語をサポートするには、システムで言語パックをダウンロードする必要があります。"
|
||||
@@ -963,11 +963,9 @@
|
||||
},
|
||||
"add_directory": "Добавить директорию",
|
||||
"add_file": "Добавить файл",
|
||||
"add_image": "добавить изображение",
|
||||
"add_note": "Добавить запись",
|
||||
"add_sitemap": "Карта сайта",
|
||||
"add_url": "Добавить URL",
|
||||
"add_video": "Добавить видео",
|
||||
"cancel_index": "Отменить индексирование",
|
||||
"chunk_overlap": "Перекрытие фрагмента",
|
||||
"chunk_overlap_placeholder": "По умолчанию (не рекомендуется изменять)",
|
||||
@@ -994,7 +992,6 @@
|
||||
"document_count_default": "По умолчанию",
|
||||
"document_count_help": "Количество запрошенных документов, вместе с ними передается больше информации, но и требуется больше токенов",
|
||||
"drag_file": "Перетащите файл сюда",
|
||||
"drag_image": "Перетащите изображение сюда",
|
||||
"edit_remark": "Изменить примечание",
|
||||
"edit_remark_placeholder": "Пожалуйста, введите содержание примечания",
|
||||
"embedding_model": "Модель встраивания",
|
||||
@@ -1003,15 +1000,9 @@
|
||||
"error": {
|
||||
"failed_to_create": "Создание базы знаний завершено с ошибками",
|
||||
"failed_to_edit": "Редактирование базы знаний завершено с ошибками",
|
||||
"model_invalid": "Модель не выбрана",
|
||||
"video": {
|
||||
"local_file_missing": "Видеофайл не найден",
|
||||
"youtube_url_missing": "URL видео YouTube не найден"
|
||||
}
|
||||
"model_invalid": "Модель не выбрана"
|
||||
},
|
||||
"file_hint": "Поддерживаются {{file_types}}",
|
||||
"image_hint": "Поддерживаются форматы {{image_types}}",
|
||||
"images": "изображение",
|
||||
"index_all": "Индексировать все",
|
||||
"index_cancelled": "Индексирование отменено",
|
||||
"index_started": "Индексирование началось",
|
||||
@@ -1028,10 +1019,6 @@
|
||||
"error": {
|
||||
"failed": "Миграция завершена с ошибками"
|
||||
},
|
||||
"migrate_to_langchain": {
|
||||
"content": "Миграция базы знаний не удаляет старую базу, а создает ее копию с последующей повторной обработкой всех записей, что может потребовать значительного количества токенов. Пожалуйста, действуйте осторожно.",
|
||||
"info": "Архитектура базы знаний обновлена, нажмите, чтобы перейти на новую архитектуру"
|
||||
},
|
||||
"source_dimensions": "Исходная размерность",
|
||||
"source_model": "Исходная модель",
|
||||
"target_dimensions": "Целевая размерность",
|
||||
@@ -1050,20 +1037,6 @@
|
||||
"quota": "{{name}} Остаток квоты: {{quota}}",
|
||||
"quota_infinity": "{{name}} Квота: Не ограничена",
|
||||
"rename": "Переименовать",
|
||||
"retriever": "Режим поиска",
|
||||
"retriever_bm25": "полнотекстовый поиск",
|
||||
"retriever_bm25_desc": "Поиск документов на основе релевантности и частоты ключевых слов.",
|
||||
"retriever_hybrid": "Гибридный поиск (рекомендуется)",
|
||||
"retriever_hybrid_desc": "Сочетание поиска по ключевым словам и семантического поиска для достижения оптимальной точности поиска.",
|
||||
"retriever_hybrid_weight": {
|
||||
"bm25": "Полный текст",
|
||||
"recommended": "рекомендовать",
|
||||
"title": "Регулировка весов гибридного поиска (полнотекстовый/векторный)",
|
||||
"vector": "вектор"
|
||||
},
|
||||
"retriever_tooltip": "Использование различных методов поиска в базе знаний",
|
||||
"retriever_vector": "векторный поиск",
|
||||
"retriever_vector_desc": "Поиск документов по семантическому сходству и смыслу.",
|
||||
"search": "Поиск в базе знаний",
|
||||
"search_placeholder": "Введите текст для поиска",
|
||||
"settings": {
|
||||
@@ -1085,7 +1058,6 @@
|
||||
"status_preprocess_completed": "Предварительная обработка завершена",
|
||||
"status_preprocess_failed": "Предварительная обработка не удалась",
|
||||
"status_processing": "Обработка",
|
||||
"subtitle_file": "Файл субтитров",
|
||||
"threshold": "Порог соответствия",
|
||||
"threshold_placeholder": "Не установлено",
|
||||
"threshold_too_large_or_small": "Порог не может быть больше 1 или меньше 0",
|
||||
@@ -1097,9 +1069,7 @@
|
||||
"topN_tooltip": "Количество возвращаемых совпадений; чем больше значение, тем больше совпадений, но и потребление токенов тоже возрастает.",
|
||||
"url_added": "URL добавлен",
|
||||
"url_placeholder": "Введите URL, несколько URL через Enter",
|
||||
"urls": "URL-адреса",
|
||||
"videos": "видео",
|
||||
"videos_file": "видеофайл"
|
||||
"urls": "URL-адреса"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "Арабский",
|
||||
@@ -1406,13 +1376,6 @@
|
||||
"bubble": "Пузырь",
|
||||
"label": "Стиль сообщения",
|
||||
"plain": "Простой"
|
||||
},
|
||||
"video": {
|
||||
"error": {
|
||||
"local_file_missing": "Путь к локальному видеофайлу не найден",
|
||||
"unsupported_type": "Неподдерживаемый тип видео",
|
||||
"youtube_url_missing": "URL видео YouTube не найден"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "Обрабатывается...",
|
||||
@@ -1653,13 +1616,7 @@
|
||||
"navbar": {
|
||||
"expand": "Развернуть диалоговое окно",
|
||||
"hide_sidebar": "Скрыть боковую панель",
|
||||
"show_sidebar": "Показать боковую панель",
|
||||
"window": {
|
||||
"close": "Закрыть",
|
||||
"maximize": "Развернуть",
|
||||
"minimize": "Свернуть",
|
||||
"restore": "Восстановить"
|
||||
}
|
||||
"show_sidebar": "Показать боковую панель"
|
||||
},
|
||||
"navigate": {
|
||||
"provider_settings": "Перейти к настройкам поставщика"
|
||||
@@ -3927,13 +3884,6 @@
|
||||
"title": "Изображение"
|
||||
},
|
||||
"image_provider": "Поставщик услуг OCR",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Токен доступа сообщества AI Studio",
|
||||
"aistudio_url_label": "Сообщество AI Studio",
|
||||
"api_url": "URL API",
|
||||
"serving_doc_url_label": "Документация по PaddleOCR Serving",
|
||||
"tip": "Вы можете обратиться к официальной документации PaddleOCR, чтобы развернуть локальный сервис, либо развернуть облачный сервис в сообществе PaddlePaddle AI Studio. В последнем случае, пожалуйста, предоставьте токен доступа сообщества AI Studio."
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
"langs_tooltip": "Для предоставления служб Windows необходимо загрузить языковой пакет в системе для поддержки соответствующего языка."
|
||||
@@ -963,11 +963,9 @@
|
||||
},
|
||||
"add_directory": "添加目录",
|
||||
"add_file": "添加文件",
|
||||
"add_image": "添加图片",
|
||||
"add_note": "添加笔记",
|
||||
"add_sitemap": "站点地图",
|
||||
"add_url": "添加网址",
|
||||
"add_video": "添加视频",
|
||||
"cancel_index": "取消索引",
|
||||
"chunk_overlap": "重叠大小",
|
||||
"chunk_overlap_placeholder": "默认值(不建议修改)",
|
||||
@@ -994,7 +992,6 @@
|
||||
"document_count_default": "默认",
|
||||
"document_count_help": "请求文档片段数量越多,附带的信息越多,但需要消耗的 Token 也越多",
|
||||
"drag_file": "拖拽文件到这里",
|
||||
"drag_image": "拖拽图片到这里",
|
||||
"edit_remark": "修改备注",
|
||||
"edit_remark_placeholder": "请输入备注内容",
|
||||
"embedding_model": "嵌入模型",
|
||||
@@ -1003,15 +1000,9 @@
|
||||
"error": {
|
||||
"failed_to_create": "知识库创建失败",
|
||||
"failed_to_edit": "知识库编辑失败",
|
||||
"model_invalid": "未选择模型",
|
||||
"video": {
|
||||
"local_file_missing": "视频文件不存在",
|
||||
"youtube_url_missing": "YouTube 视频链接不存在"
|
||||
}
|
||||
"model_invalid": "未选择模型"
|
||||
},
|
||||
"file_hint": "支持 {{file_types}} 格式",
|
||||
"image_hint": "支持 {{image_types}} 格式",
|
||||
"images": "图片",
|
||||
"index_all": "索引全部",
|
||||
"index_cancelled": "索引已取消",
|
||||
"index_started": "索引开始",
|
||||
@@ -1028,10 +1019,6 @@
|
||||
"error": {
|
||||
"failed": "迁移失败"
|
||||
},
|
||||
"migrate_to_langchain": {
|
||||
"content": "知识库迁移不会删除旧知识库,而是创建一个副本之后重新处理所有知识库条目,可能消耗大量 tokens,请谨慎操作。",
|
||||
"info": "知识库架构已更新,点击迁移到新架构"
|
||||
},
|
||||
"source_dimensions": "源维度",
|
||||
"source_model": "源模型",
|
||||
"target_dimensions": "目标维度",
|
||||
@@ -1050,20 +1037,6 @@
|
||||
"quota": "{{name}} 剩余额度:{{quota}}",
|
||||
"quota_infinity": "{{name}} 剩余额度:无限制",
|
||||
"rename": "重命名",
|
||||
"retriever": "检索模式",
|
||||
"retriever_bm25": "全文搜索",
|
||||
"retriever_bm25_desc": "根据关键字的相关性和频率查找文档。",
|
||||
"retriever_hybrid": "混合搜索 (推荐)",
|
||||
"retriever_hybrid_desc": "结合关键词搜索和语义搜索,以实现最佳检索准确性。",
|
||||
"retriever_hybrid_weight": {
|
||||
"bm25": "全文",
|
||||
"recommended": "推荐",
|
||||
"title": "混合搜索权重调整 (全文/向量)",
|
||||
"vector": "向量"
|
||||
},
|
||||
"retriever_tooltip": "使用不同的检索方式检索知识库",
|
||||
"retriever_vector": "向量搜索",
|
||||
"retriever_vector_desc": "根据语义相似性和含义查找文档。",
|
||||
"search": "搜索知识库",
|
||||
"search_placeholder": "输入查询内容",
|
||||
"settings": {
|
||||
@@ -1085,7 +1058,6 @@
|
||||
"status_preprocess_completed": "预处理完成",
|
||||
"status_preprocess_failed": "预处理失败",
|
||||
"status_processing": "处理中",
|
||||
"subtitle_file": "字幕文件",
|
||||
"threshold": "匹配度阈值",
|
||||
"threshold_placeholder": "未设置",
|
||||
"threshold_too_large_or_small": "阈值不能大于 1 或小于 0",
|
||||
@@ -1097,9 +1069,7 @@
|
||||
"topN_tooltip": "返回的匹配结果数量,数值越大,匹配结果越多,但消耗的 Token 也越多",
|
||||
"url_added": "网址已添加",
|
||||
"url_placeholder": "请输入网址, 多个网址用回车分隔",
|
||||
"urls": "网址",
|
||||
"videos": "视频",
|
||||
"videos_file": "视频文件"
|
||||
"urls": "网址"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "阿拉伯文",
|
||||
@@ -1406,13 +1376,6 @@
|
||||
"bubble": "气泡",
|
||||
"label": "消息样式",
|
||||
"plain": "简洁"
|
||||
},
|
||||
"video": {
|
||||
"error": {
|
||||
"local_file_missing": "本地视频文件路径不存在",
|
||||
"unsupported_type": "不支持的视频类型",
|
||||
"youtube_url_missing": "YouTube 视频链接不存在"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "正在处理...",
|
||||
@@ -1653,13 +1616,7 @@
|
||||
"navbar": {
|
||||
"expand": "伸缩对话框",
|
||||
"hide_sidebar": "隐藏侧边栏",
|
||||
"show_sidebar": "显示侧边栏",
|
||||
"window": {
|
||||
"close": "关闭",
|
||||
"maximize": "最大化",
|
||||
"minimize": "最小化",
|
||||
"restore": "还原"
|
||||
}
|
||||
"show_sidebar": "显示侧边栏"
|
||||
},
|
||||
"navigate": {
|
||||
"provider_settings": "跳转到服务商设置界面"
|
||||
@@ -3927,13 +3884,6 @@
|
||||
"title": "图片"
|
||||
},
|
||||
"image_provider": "OCR 服务提供商",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "星河社区访问令牌",
|
||||
"aistudio_url_label": "星河社区",
|
||||
"api_url": "API URL",
|
||||
"serving_doc_url_label": "PaddleOCR 服务化部署文档",
|
||||
"tip": "您可以参考 PaddleOCR 官方文档部署本地服务,或者在飞桨星河社区部署云服务。对于后一种情况,请填写星河社区访问令牌。"
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
"langs_tooltip": "依赖 Windows 提供服务,您需要在系统中下载语言包来支持相关语言。"
|
||||
|
||||
@@ -963,11 +963,9 @@
|
||||
},
|
||||
"add_directory": "新增目錄",
|
||||
"add_file": "新增檔案",
|
||||
"add_image": "新增圖片",
|
||||
"add_note": "新增筆記",
|
||||
"add_sitemap": "網站地圖",
|
||||
"add_url": "新增網址",
|
||||
"add_video": "新增影片",
|
||||
"cancel_index": "取消索引",
|
||||
"chunk_overlap": "重疊大小",
|
||||
"chunk_overlap_placeholder": "預設值(不建議修改)",
|
||||
@@ -994,7 +992,6 @@
|
||||
"document_count_default": "預設",
|
||||
"document_count_help": "請求文件片段數量越多,附帶的資訊越多,但需要消耗的 Token 也越多",
|
||||
"drag_file": "拖拽檔案到這裡",
|
||||
"drag_image": "拖曳圖片到這裡",
|
||||
"edit_remark": "修改備註",
|
||||
"edit_remark_placeholder": "請輸入備註內容",
|
||||
"embedding_model": "嵌入模型",
|
||||
@@ -1003,15 +1000,9 @@
|
||||
"error": {
|
||||
"failed_to_create": "知識庫創建失敗",
|
||||
"failed_to_edit": "知識庫編輯失敗",
|
||||
"model_invalid": "未選擇模型",
|
||||
"video": {
|
||||
"local_file_missing": "影片檔案不存在",
|
||||
"youtube_url_missing": "YouTube 影片連結不存在"
|
||||
}
|
||||
"model_invalid": "未選擇模型"
|
||||
},
|
||||
"file_hint": "支援 {{file_types}} 格式",
|
||||
"image_hint": "支援 {{image_types}} 格式",
|
||||
"images": "圖片",
|
||||
"index_all": "索引全部",
|
||||
"index_cancelled": "索引已取消",
|
||||
"index_started": "索引開始",
|
||||
@@ -1028,10 +1019,6 @@
|
||||
"error": {
|
||||
"failed": "遷移失敗"
|
||||
},
|
||||
"migrate_to_langchain": {
|
||||
"content": "知識庫遷移不會刪除舊知識庫,而是建立一個副本後重新處理所有知識庫條目,可能消耗大量 tokens,請謹慎操作。",
|
||||
"info": "知識庫架構已更新,點擊遷移到新架構"
|
||||
},
|
||||
"source_dimensions": "源維度",
|
||||
"source_model": "源模型",
|
||||
"target_dimensions": "目標維度",
|
||||
@@ -1050,20 +1037,6 @@
|
||||
"quota": "{{name}} 剩餘配額:{{quota}}",
|
||||
"quota_infinity": "{{name}} 配額:無限制",
|
||||
"rename": "重新命名",
|
||||
"retriever": "搜尋模式",
|
||||
"retriever_bm25": "全文搜尋",
|
||||
"retriever_bm25_desc": "根據關鍵字的相關性和頻率查找文件。",
|
||||
"retriever_hybrid": "混合搜尋(推薦)",
|
||||
"retriever_hybrid_desc": "結合關鍵字搜索和語義搜索,以實現最佳檢索準確性。",
|
||||
"retriever_hybrid_weight": {
|
||||
"bm25": "全文",
|
||||
"recommended": "推薦",
|
||||
"title": "混合搜尋權重調整 (全文/向量)",
|
||||
"vector": "向量"
|
||||
},
|
||||
"retriever_tooltip": "使用不同的檢索方式檢索知識庫",
|
||||
"retriever_vector": "向量搜尋",
|
||||
"retriever_vector_desc": "根據語意相似性和含義查找文件。",
|
||||
"search": "搜尋知識庫",
|
||||
"search_placeholder": "輸入查詢內容",
|
||||
"settings": {
|
||||
@@ -1085,7 +1058,6 @@
|
||||
"status_preprocess_completed": "預處理完成",
|
||||
"status_preprocess_failed": "預處理失敗",
|
||||
"status_processing": "處理中",
|
||||
"subtitle_file": "字幕檔案",
|
||||
"threshold": "匹配度閾值",
|
||||
"threshold_placeholder": "未設定",
|
||||
"threshold_too_large_or_small": "閾值不能大於 1 或小於 0",
|
||||
@@ -1097,9 +1069,7 @@
|
||||
"topN_tooltip": "返回的匹配結果數量,數值越大,匹配結果越多,但消耗的 Token 也越多",
|
||||
"url_added": "網址已新增",
|
||||
"url_placeholder": "請輸入網址,多個網址用換行符號分隔",
|
||||
"urls": "網址",
|
||||
"videos": "影片",
|
||||
"videos_file": "影片檔案"
|
||||
"urls": "網址"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "阿拉伯文",
|
||||
@@ -1406,13 +1376,6 @@
|
||||
"bubble": "氣泡",
|
||||
"label": "訊息樣式",
|
||||
"plain": "簡潔"
|
||||
},
|
||||
"video": {
|
||||
"error": {
|
||||
"local_file_missing": "本地視頻檔案路徑不存在",
|
||||
"unsupported_type": "不支援的視頻類型",
|
||||
"youtube_url_missing": "YouTube 視頻連結不存在"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "正在處理...",
|
||||
@@ -1653,13 +1616,7 @@
|
||||
"navbar": {
|
||||
"expand": "伸縮對話框",
|
||||
"hide_sidebar": "隱藏側邊欄",
|
||||
"show_sidebar": "顯示側邊欄",
|
||||
"window": {
|
||||
"close": "關閉",
|
||||
"maximize": "最大化",
|
||||
"minimize": "最小化",
|
||||
"restore": "還原"
|
||||
}
|
||||
"show_sidebar": "顯示側邊欄"
|
||||
},
|
||||
"navigate": {
|
||||
"provider_settings": "跳轉到服務商設置界面"
|
||||
@@ -3927,13 +3884,6 @@
|
||||
"title": "圖片"
|
||||
},
|
||||
"image_provider": "OCR 服務提供商",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "星河社群存取權杖",
|
||||
"aistudio_url_label": "星河社群",
|
||||
"api_url": "API 網址",
|
||||
"serving_doc_url_label": "PaddleOCR 服務化部署文件",
|
||||
"tip": "您可以參考 PaddleOCR 官方文件來部署本機服務,或是在飛槳星河社群部署雲端服務。對於後者,請提供星河社群的存取權杖。"
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
"langs_tooltip": "依賴 Windows 提供服務,您需要在系統中下載語言包來支援相關語言。"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user