Compare commits

..

2 Commits

Author SHA1 Message Date
MyPrototypeWhat
bd6d6bd56e refactor: update ClaudeCodeService initialization to remove config parameter
- Modified the initialization of the Claude code provider in ClaudeCodeService to no longer accept a configuration parameter, simplifying the setup process.
- This change enhances clarity and reduces potential configuration errors during provider instantiation.
2025-09-04 17:37:10 +08:00
MyPrototypeWhat
7a23386de4 feat: enhance AI core with Claude code integration and new provider support
- Added ClaudeCodeService for managing Claude code interactions via HTTP.
- Updated IPC channels to include new provider functionalities, enabling communication with the Claude code service.
- Enhanced electron configuration to support new AI core paths and dependencies.
- Updated package.json to include new dependencies for AI SDK and express.
- Refactored tsconfig to include paths for the new AI core modules, improving module resolution.

This update improves the integration of AI capabilities and enhances the overall functionality of the application.
2025-09-04 17:21:50 +08:00
169 changed files with 1830 additions and 7569 deletions

View File

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

View File

@@ -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:*)"'

View File

@@ -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:*)'

View File

@@ -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服务商连接失败的问题
- 修复模型配置加载时的潜在错误
- 提升应用整体稳定性和容错能力

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,63 +0,0 @@
import { VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
import type { Embeddings } from '@langchain/core/embeddings'
import { OllamaEmbeddings } from '@langchain/ollama'
import { AzureOpenAIEmbeddings, OpenAIEmbeddings } from '@langchain/openai'
import { ApiClient, SystemProviderIds } from '@types'
import { isJinaEmbeddingsModel, JinaEmbeddings } from './JinaEmbeddings'
export default class EmbeddingsFactory {
static create({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }): Embeddings {
const batchSize = 10
const { model, provider, apiKey, apiVersion, baseURL } = embedApiClient
if (provider === SystemProviderIds.ollama) {
let baseUrl = baseURL
if (baseURL.includes('v1/')) {
baseUrl = baseURL.replace('v1/', '')
}
const headers = apiKey
? {
Authorization: `Bearer ${apiKey}`
}
: undefined
return new OllamaEmbeddings({
model: model,
baseUrl,
...headers
})
} else if (provider === SystemProviderIds.voyageai) {
return new VoyageEmbeddings({
modelName: model,
apiKey,
outputDimension: dimensions,
batchSize
})
}
if (isJinaEmbeddingsModel(model)) {
return new JinaEmbeddings({
model,
apiKey,
batchSize,
dimensions,
baseUrl: baseURL
})
}
if (apiVersion !== undefined) {
return new AzureOpenAIEmbeddings({
azureOpenAIApiKey: apiKey,
azureOpenAIApiVersion: apiVersion,
azureOpenAIApiDeploymentName: model,
azureOpenAIEndpoint: baseURL,
dimensions,
batchSize
})
}
return new OpenAIEmbeddings({
model,
apiKey,
dimensions,
batchSize,
configuration: { baseURL }
})
}
}

View File

@@ -1,199 +0,0 @@
import { Embeddings, type EmbeddingsParams } from '@langchain/core/embeddings'
import { chunkArray } from '@langchain/core/utils/chunk_array'
import { getEnvironmentVariable } from '@langchain/core/utils/env'
import z from 'zod/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)
}
}

View File

@@ -1,25 +0,0 @@
import type { Embeddings as BaseEmbeddings } from '@langchain/core/embeddings'
import { TraceMethod } from '@mcp-trace/trace-core'
import { ApiClient } from '@types'
import EmbeddingsFactory from './EmbeddingsFactory'
export default class TextEmbeddings {
private sdk: BaseEmbeddings
constructor({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }) {
this.sdk = EmbeddingsFactory.create({
embedApiClient,
dimensions
})
}
@TraceMethod({ spanName: 'embedDocuments', tag: 'Embeddings' })
public async embedDocuments(texts: string[]): Promise<number[][]> {
return this.sdk.embedDocuments(texts)
}
@TraceMethod({ spanName: 'embedQuery', tag: 'Embeddings' })
public async embedQuery(text: string): Promise<number[]> {
return this.sdk.embedQuery(text)
}
}

View File

@@ -1,97 +0,0 @@
import { BaseDocumentLoader } from '@langchain/core/document_loaders/base'
import { Document } from '@langchain/core/documents'
import { readTextFileWithAutoEncoding } from '@main/utils/file'
import MarkdownIt from 'markdown-it'
export class MarkdownLoader extends BaseDocumentLoader {
private path: string
private md: MarkdownIt
constructor(path: string) {
super()
this.path = path
this.md = new MarkdownIt()
}
public async load(): Promise<Document[]> {
const content = await readTextFileWithAutoEncoding(this.path)
return this.parseMarkdown(content)
}
private parseMarkdown(content: string): Document[] {
const tokens = this.md.parse(content, {})
const documents: Document[] = []
let currentSection: {
heading?: string
level?: number
content: string
startLine?: number
} = { content: '' }
let i = 0
while (i < tokens.length) {
const token = tokens[i]
if (token.type === 'heading_open') {
// Save previous section if it has content
if (currentSection.content.trim()) {
documents.push(
new Document({
pageContent: currentSection.content.trim(),
metadata: {
source: this.path,
heading: currentSection.heading || 'Introduction',
level: currentSection.level || 0,
startLine: currentSection.startLine || 0
}
})
)
}
// Start new section
const level = parseInt(token.tag.slice(1)) // Extract number from h1, h2, etc.
const headingContent = tokens[i + 1]?.content || ''
currentSection = {
heading: headingContent,
level: level,
content: '',
startLine: token.map?.[0] || 0
}
// Skip heading_open, inline, heading_close tokens
i += 3
continue
}
// Add token content to current section
if (token.content) {
currentSection.content += token.content
}
// Add newlines for block tokens
if (token.block && token.type !== 'heading_close') {
currentSection.content += '\n'
}
i++
}
// Add the last section
if (currentSection.content.trim()) {
documents.push(
new Document({
pageContent: currentSection.content.trim(),
metadata: {
source: this.path,
heading: currentSection.heading || 'Introduction',
level: currentSection.level || 0,
startLine: currentSection.startLine || 0
}
})
)
}
return documents
}
}

View File

@@ -1,50 +0,0 @@
import { BaseDocumentLoader } from '@langchain/core/document_loaders/base'
import { Document } from '@langchain/core/documents'
export class NoteLoader extends BaseDocumentLoader {
private text: string
private sourceUrl?: string
constructor(
public _text: string,
public _sourceUrl?: string
) {
super()
this.text = _text
this.sourceUrl = _sourceUrl
}
/**
* A protected method that takes a `raw` string as a parameter and returns
* a promise that resolves to an array containing the raw text as a single
* element.
* @param raw The raw text to be parsed.
* @returns A promise that resolves to an array containing the raw text as a single element.
*/
protected async parse(raw: string): Promise<string[]> {
return [raw]
}
public async load(): Promise<Document[]> {
const metadata = { source: this.sourceUrl || 'note' }
const parsed = await this.parse(this.text)
parsed.forEach((pageContent, i) => {
if (typeof pageContent !== 'string') {
throw new Error(`Expected string, at position ${i} got ${typeof pageContent}`)
}
})
return parsed.map(
(pageContent, i) =>
new Document({
pageContent,
metadata:
parsed.length === 1
? metadata
: {
...metadata,
line: i + 1
}
})
)
}
}

View File

@@ -1,170 +0,0 @@
import { BaseDocumentLoader } from '@langchain/core/document_loaders/base'
import { Document } from '@langchain/core/documents'
import { Innertube } from 'youtubei.js'
// ... (接口定义 YoutubeConfig 和 VideoMetadata 保持不变)
/**
* Configuration options for the YoutubeLoader class. Includes properties
* such as the videoId, language, and addVideoInfo.
*/
interface YoutubeConfig {
videoId: string
language?: string
addVideoInfo?: boolean
// 新增一个选项,用于控制输出格式
transcriptFormat?: 'text' | 'srt'
}
/**
* Metadata of a YouTube video. Includes properties such as the source
* (videoId), description, title, view_count, author, and category.
*/
interface VideoMetadata {
source: string
description?: string
title?: string
view_count?: number
author?: string
category?: string
}
/**
* A document loader for loading data from YouTube videos. It uses the
* youtubei.js library to fetch the transcript and video metadata.
* @example
* ```typescript
* const loader = new YoutubeLoader({
* videoId: "VIDEO_ID",
* language: "en",
* addVideoInfo: true,
* transcriptFormat: "srt" // 获取 SRT 格式
* });
* const docs = await loader.load();
* console.log(docs[0].pageContent);
* ```
*/
export class YoutubeLoader extends BaseDocumentLoader {
private videoId: string
private language?: string
private addVideoInfo: boolean
// 新增格式化选项的私有属性
private transcriptFormat: 'text' | 'srt'
constructor(config: YoutubeConfig) {
super()
this.videoId = config.videoId
this.language = config?.language
this.addVideoInfo = config?.addVideoInfo ?? false
// 初始化格式化选项,默认为 'text' 以保持向后兼容
this.transcriptFormat = config?.transcriptFormat ?? 'text'
}
/**
* Extracts the videoId from a YouTube video URL.
* @param url The URL of the YouTube video.
* @returns The videoId of the YouTube video.
*/
private static getVideoID(url: string): string {
const match = url.match(/.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#&?]*).*/)
if (match !== null && match[1].length === 11) {
return match[1]
} else {
throw new Error('Failed to get youtube video id from the url')
}
}
/**
* Creates a new instance of the YoutubeLoader class from a YouTube video
* URL.
* @param url The URL of the YouTube video.
* @param config Optional configuration options for the YoutubeLoader instance, excluding the videoId.
* @returns A new instance of the YoutubeLoader class.
*/
static createFromUrl(url: string, config?: Omit<YoutubeConfig, 'videoId'>): YoutubeLoader {
const videoId = YoutubeLoader.getVideoID(url)
return new YoutubeLoader({ ...config, videoId })
}
/**
* [新增] 辅助函数:将毫秒转换为 SRT 时间戳格式 (HH:MM:SS,ms)
* @param ms 毫秒数
* @returns 格式化后的时间字符串
*/
private static formatTimestamp(ms: number): string {
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
.toString()
.padStart(2, '0')
const minutes = Math.floor((totalSeconds % 3600) / 60)
.toString()
.padStart(2, '0')
const seconds = (totalSeconds % 60).toString().padStart(2, '0')
const milliseconds = (ms % 1000).toString().padStart(3, '0')
return `${hours}:${minutes}:${seconds},${milliseconds}`
}
/**
* Loads the transcript and video metadata from the specified YouTube
* video. It can return the transcript as plain text or in SRT format.
* @returns An array of Documents representing the retrieved data.
*/
async load(): Promise<Document[]> {
const metadata: VideoMetadata = {
source: this.videoId
}
try {
const youtube = await Innertube.create({
lang: this.language,
retrieve_player: false
})
const info = await youtube.getInfo(this.videoId)
const transcriptData = await info.getTranscript()
if (!transcriptData.transcript.content?.body?.initial_segments) {
throw new Error('Transcript segments not found in the response.')
}
const segments = transcriptData.transcript.content.body.initial_segments
let pageContent: string
// 根据 transcriptFormat 选项决定如何格式化字幕
if (this.transcriptFormat === 'srt') {
// [修改] 将字幕片段格式化为 SRT 格式
pageContent = segments
.map((segment, index) => {
const srtIndex = index + 1
const startTime = YoutubeLoader.formatTimestamp(Number(segment.start_ms))
const endTime = YoutubeLoader.formatTimestamp(Number(segment.end_ms))
const text = segment.snippet?.text || '' // 使用 segment.snippet.text
return `${srtIndex}\n${startTime} --> ${endTime}\n${text}`
})
.join('\n\n') // 每个 SRT 块之间用两个换行符分隔
} else {
// [原始逻辑] 拼接为纯文本
pageContent = segments.map((segment) => segment.snippet?.text || '').join(' ')
}
if (this.addVideoInfo) {
const basicInfo = info.basic_info
metadata.description = basicInfo.short_description
metadata.title = basicInfo.title
metadata.view_count = basicInfo.view_count
metadata.author = basicInfo.author
}
const document = new Document({
pageContent,
metadata
})
return [document]
} catch (e: unknown) {
throw new Error(`Failed to get YouTube video transcription: ${(e as Error).message}`)
}
}
}

View File

@@ -1,235 +0,0 @@
import { DocxLoader } from '@langchain/community/document_loaders/fs/docx'
import { EPubLoader } from '@langchain/community/document_loaders/fs/epub'
import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf'
import { PPTXLoader } from '@langchain/community/document_loaders/fs/pptx'
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio'
import { SitemapLoader } from '@langchain/community/document_loaders/web/sitemap'
import { FaissStore } from '@langchain/community/vectorstores/faiss'
import { Document } from '@langchain/core/documents'
import { loggerService } from '@logger'
import { UrlSource } from '@main/utils/knowledge'
import { LoaderReturn } from '@shared/config/types'
import { FileMetadata, FileTypes, KnowledgeBaseParams } from '@types'
import { randomUUID } from 'crypto'
import { JSONLoader } from 'langchain/document_loaders/fs/json'
import { TextLoader } from 'langchain/document_loaders/fs/text'
import { SplitterFactory } from '../splitter'
import { MarkdownLoader } from './MarkdownLoader'
import { NoteLoader } from './NoteLoader'
import { YoutubeLoader } from './YoutubeLoader'
const logger = loggerService.withContext('KnowledgeService File Loader')
type LoaderInstance =
| TextLoader
| PDFLoader
| PPTXLoader
| DocxLoader
| JSONLoader
| EPubLoader
| CheerioWebBaseLoader
| YoutubeLoader
| SitemapLoader
| NoteLoader
| MarkdownLoader
/**
* 为文档数组中的每个文档的 metadata 添加类型信息。
*/
function formatDocument(docs: Document[], type: string): Document[] {
return docs.map((doc) => ({
...doc,
metadata: {
...doc.metadata,
type: type
}
}))
}
/**
* 通用文档处理管道
*/
async function processDocuments(
base: KnowledgeBaseParams,
vectorStore: FaissStore,
docs: Document[],
loaderType: string,
splitterType?: string
): Promise<LoaderReturn> {
const formattedDocs = formatDocument(docs, loaderType)
const splitter = SplitterFactory.create({
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap,
...(splitterType && { type: splitterType })
})
const splitterResults = await splitter.splitDocuments(formattedDocs)
const ids = splitterResults.map(() => randomUUID())
await vectorStore.addDocuments(splitterResults, { ids })
return {
entriesAdded: splitterResults.length,
uniqueId: ids[0] || '',
uniqueIds: ids,
loaderType
}
}
/**
* 通用加载器执行函数
*/
async function executeLoader(
base: KnowledgeBaseParams,
vectorStore: FaissStore,
loaderInstance: LoaderInstance,
loaderType: string,
identifier: string,
splitterType?: string
): Promise<LoaderReturn> {
const emptyResult: LoaderReturn = {
entriesAdded: 0,
uniqueId: '',
uniqueIds: [],
loaderType
}
try {
const docs = await loaderInstance.load()
return await processDocuments(base, vectorStore, docs, loaderType, splitterType)
} catch (error) {
logger.error(`Error loading or processing ${identifier} with loader ${loaderType}: ${error}`)
return emptyResult
}
}
/**
* 文件扩展名到加载器的映射
*/
const FILE_LOADER_MAP: Record<string, { loader: new (path: string) => LoaderInstance; type: string }> = {
'.pdf': { loader: PDFLoader, type: 'pdf' },
'.txt': { loader: TextLoader, type: 'text' },
'.pptx': { loader: PPTXLoader, type: 'pptx' },
'.docx': { loader: DocxLoader, type: 'docx' },
'.doc': { loader: DocxLoader, type: 'doc' },
'.json': { loader: JSONLoader, type: 'json' },
'.epub': { loader: EPubLoader, type: 'epub' },
'.md': { loader: MarkdownLoader, type: 'markdown' }
}
export async function addFileLoader(
base: KnowledgeBaseParams,
vectorStore: FaissStore,
file: FileMetadata
): Promise<LoaderReturn> {
const fileExt = file.ext.toLowerCase()
const loaderConfig = FILE_LOADER_MAP[fileExt]
if (!loaderConfig) {
// 默认使用文本加载器
const loaderInstance = new TextLoader(file.path)
const type = fileExt.replace('.', '') || 'unknown'
return executeLoader(base, vectorStore, loaderInstance, type, file.path)
}
const loaderInstance = new loaderConfig.loader(file.path)
return executeLoader(base, vectorStore, loaderInstance, loaderConfig.type, file.path)
}
export async function addWebLoader(
base: KnowledgeBaseParams,
vectorStore: FaissStore,
url: string,
source: UrlSource
): Promise<LoaderReturn> {
let loaderInstance: CheerioWebBaseLoader | YoutubeLoader | undefined
let splitterType: string | undefined
switch (source) {
case 'normal':
loaderInstance = new CheerioWebBaseLoader(url)
break
case 'youtube':
loaderInstance = YoutubeLoader.createFromUrl(url, {
addVideoInfo: true,
transcriptFormat: 'srt'
})
splitterType = 'srt'
break
}
if (!loaderInstance) {
return {
entriesAdded: 0,
uniqueId: '',
uniqueIds: [],
loaderType: source
}
}
return executeLoader(base, vectorStore, loaderInstance, source, url, splitterType)
}
export async function addSitemapLoader(
base: KnowledgeBaseParams,
vectorStore: FaissStore,
url: string
): Promise<LoaderReturn> {
const loaderInstance = new SitemapLoader(url)
return executeLoader(base, vectorStore, loaderInstance, 'sitemap', url)
}
export async function addNoteLoader(
base: KnowledgeBaseParams,
vectorStore: FaissStore,
content: string,
sourceUrl: string
): Promise<LoaderReturn> {
const loaderInstance = new NoteLoader(content, sourceUrl)
return executeLoader(base, vectorStore, loaderInstance, 'note', sourceUrl)
}
export async function addVideoLoader(
base: KnowledgeBaseParams,
vectorStore: FaissStore,
files: FileMetadata[]
): Promise<LoaderReturn> {
const srtFile = files.find((f) => f.type === FileTypes.TEXT)
const videoFile = files.find((f) => f.type === FileTypes.VIDEO)
const emptyResult: LoaderReturn = {
entriesAdded: 0,
uniqueId: '',
uniqueIds: [],
loaderType: 'video'
}
if (!srtFile || !videoFile) {
return emptyResult
}
try {
const loaderInstance = new TextLoader(srtFile.path)
const originalDocs = await loaderInstance.load()
const docsWithVideoMeta = originalDocs.map(
(doc) =>
new Document({
...doc,
metadata: {
...doc.metadata,
video: {
path: videoFile.path,
name: videoFile.origin_name
}
}
})
)
return await processDocuments(base, vectorStore, docsWithVideoMeta, 'video', 'srt')
} catch (error) {
logger.error(`Error loading or processing file ${srtFile.path} with loader video: ${error}`)
return emptyResult
}
}

View File

@@ -1,55 +0,0 @@
import { BM25Retriever } from '@langchain/community/retrievers/bm25'
import { FaissStore } from '@langchain/community/vectorstores/faiss'
import { BaseRetriever } from '@langchain/core/retrievers'
import { loggerService } from '@main/services/LoggerService'
import { type KnowledgeBaseParams } from '@types'
import { type Document } from 'langchain/document'
import { EnsembleRetriever } from 'langchain/retrievers/ensemble'
const logger = loggerService.withContext('RetrieverFactory')
export class RetrieverFactory {
/**
* 根据提供的参数创建一个 LangChain 检索器 (Retriever)。
* @param base 知识库配置参数。
* @param vectorStore 一个已初始化的向量存储实例。
* @param documents 文档列表,用于初始化 BM25Retriever。
* @returns 返回一个 BaseRetriever 实例。
*/
public createRetriever(base: KnowledgeBaseParams, vectorStore: FaissStore, documents: Document[]): BaseRetriever {
const retrieverType = base.retriever?.mode ?? 'hybrid'
const retrieverWeight = base.retriever?.weight ?? 0.5
const searchK = base.documentCount ?? 5
logger.info(`Creating retriever of type: ${retrieverType} with k=${searchK}`)
switch (retrieverType) {
case 'bm25':
if (documents.length === 0) {
throw new Error('BM25Retriever requires documents, but none were provided or found.')
}
logger.info('Create BM25 Retriever')
return BM25Retriever.fromDocuments(documents, { k: searchK })
case 'hybrid': {
if (documents.length === 0) {
logger.warn('No documents provided for BM25 part of hybrid search. Falling back to vector search only.')
return vectorStore.asRetriever(searchK)
}
const vectorstoreRetriever = vectorStore.asRetriever(searchK)
const bm25Retriever = BM25Retriever.fromDocuments(documents, { k: searchK })
logger.info('Create Hybrid Retriever')
return new EnsembleRetriever({
retrievers: [bm25Retriever, vectorstoreRetriever],
weights: [retrieverWeight, 1 - retrieverWeight]
})
}
case 'vector':
default:
logger.info('Create Vector Retriever')
return vectorStore.asRetriever(searchK)
}
}
}

View File

@@ -1,133 +0,0 @@
import { Document } from '@langchain/core/documents'
import { TextSplitter, TextSplitterParams } from 'langchain/text_splitter'
// 定义一个接口来表示解析后的单个字幕片段
interface SrtSegment {
text: string
startTime: number // in seconds
endTime: number // in seconds
}
// 辅助函数:将 SRT 时间戳字符串 (HH:MM:SS,ms) 转换为秒
function srtTimeToSeconds(time: string): number {
const parts = time.split(':')
const secondsAndMs = parts[2].split(',')
const hours = parseInt(parts[0], 10)
const minutes = parseInt(parts[1], 10)
const seconds = parseInt(secondsAndMs[0], 10)
const milliseconds = parseInt(secondsAndMs[1], 10)
return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000
}
export class SrtSplitter extends TextSplitter {
constructor(fields?: Partial<TextSplitterParams>) {
// 传入 chunkSize 和 chunkOverlap
super(fields)
}
splitText(): Promise<string[]> {
throw new Error('Method not implemented.')
}
// 核心方法:重写 splitDocuments 来实现自定义逻辑
async splitDocuments(documents: Document[]): Promise<Document[]> {
const allChunks: Document[] = []
for (const doc of documents) {
// 1. 解析 SRT 内容
const segments = this.parseSrt(doc.pageContent)
if (segments.length === 0) continue
// 2. 将字幕片段组合成块
const chunks = this.mergeSegmentsIntoChunks(segments, doc.metadata)
allChunks.push(...chunks)
}
return allChunks
}
// 辅助方法:解析整个 SRT 字符串
private parseSrt(srt: string): SrtSegment[] {
const segments: SrtSegment[] = []
const blocks = srt.trim().split(/\n\n/)
for (const block of blocks) {
const lines = block.split('\n')
if (lines.length < 3) continue
const timeMatch = lines[1].match(/(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})/)
if (!timeMatch) continue
const startTime = srtTimeToSeconds(timeMatch[1])
const endTime = srtTimeToSeconds(timeMatch[2])
const text = lines.slice(2).join(' ').trim()
segments.push({ text, startTime, endTime })
}
return segments
}
// 辅助方法:将解析后的片段合并成每 5 段一个块
private mergeSegmentsIntoChunks(segments: SrtSegment[], baseMetadata: Record<string, any>): Document[] {
const chunks: Document[] = []
let currentChunkText = ''
let currentChunkStartTime = 0
let currentChunkEndTime = 0
let segmentCount = 0
for (const segment of segments) {
if (segmentCount === 0) {
currentChunkStartTime = segment.startTime
}
currentChunkText += (currentChunkText ? ' ' : '') + segment.text
currentChunkEndTime = segment.endTime
segmentCount++
// 当累积到 5 段时,创建一个新的 Document
if (segmentCount === 5) {
const metadata: Record<string, any> = {
...baseMetadata,
startTime: currentChunkStartTime,
endTime: currentChunkEndTime
}
if (baseMetadata.source_url) {
metadata.source_url_with_timestamp = `${baseMetadata.source_url}?t=${Math.floor(currentChunkStartTime)}s`
}
chunks.push(
new Document({
pageContent: currentChunkText,
metadata
})
)
// 重置计数器和临时变量
currentChunkText = ''
currentChunkStartTime = 0
currentChunkEndTime = 0
segmentCount = 0
}
}
// 如果还有剩余的片段,创建最后一个 Document
if (segmentCount > 0) {
const metadata: Record<string, any> = {
...baseMetadata,
startTime: currentChunkStartTime,
endTime: currentChunkEndTime
}
if (baseMetadata.source_url) {
metadata.source_url_with_timestamp = `${baseMetadata.source_url}?t=${Math.floor(currentChunkStartTime)}s`
}
chunks.push(
new Document({
pageContent: currentChunkText,
metadata
})
)
}
return chunks
}
}

View File

@@ -1,31 +0,0 @@
import { RecursiveCharacterTextSplitter, TextSplitter } from '@langchain/textsplitters'
import { SrtSplitter } from './SrtSplitter'
export type SplitterConfig = {
chunkSize?: number
chunkOverlap?: number
type?: 'recursive' | 'srt' | string
}
export class SplitterFactory {
/**
* Creates a TextSplitter instance based on the provided configuration.
* @param config - The configuration object specifying the splitter type and its parameters.
* @returns An instance of a TextSplitter, or null if no splitting is required.
*/
public static create(config: SplitterConfig): TextSplitter {
switch (config.type) {
case 'srt':
return new SrtSplitter({
chunkSize: config.chunkSize,
chunkOverlap: config.chunkOverlap
})
case 'recursive':
default:
return new RecursiveCharacterTextSplitter({
chunkSize: config.chunkSize,
chunkOverlap: config.chunkOverlap
})
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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()

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,72 +0,0 @@
import { LoaderReturn } from '@shared/config/types'
import { KnowledgeBaseParams, KnowledgeItem, KnowledgeSearchResult } from '@types'
export interface KnowledgeBaseAddItemOptions {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload?: boolean
userId?: string
}
export interface KnowledgeBaseAddItemOptionsNonNullableAttribute {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload: boolean
userId: string
}
export interface EvaluateTaskWorkload {
workload: number
}
export type LoaderDoneReturn = LoaderReturn | null
export enum LoaderTaskItemState {
PENDING,
PROCESSING,
DONE
}
export interface LoaderTaskItem {
state: LoaderTaskItemState
task: () => Promise<unknown>
evaluateTaskWorkload: EvaluateTaskWorkload
}
export interface LoaderTask {
loaderTasks: LoaderTaskItem[]
loaderDoneReturn: LoaderDoneReturn
}
export interface LoaderTaskOfSet {
loaderTasks: Set<LoaderTaskItem>
loaderDoneReturn: LoaderDoneReturn
}
export interface QueueTaskItem {
taskPromise: () => Promise<unknown>
resolve: () => void
evaluateTaskWorkload: EvaluateTaskWorkload
}
export const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => {
return {
loaderTasks: new Set(loaderTask.loaderTasks),
loaderDoneReturn: loaderTask.loaderDoneReturn
}
}
export interface IKnowledgeFramework {
/** 为给定知识库初始化框架资源 */
initialize(base: KnowledgeBaseParams): Promise<void>
/** 重置知识库,删除其所有内容 */
reset(base: KnowledgeBaseParams): Promise<void>
/** 删除与知识库关联的资源,包括文件 */
delete(id: string): Promise<void>
/** 生成用于添加条目的任务对象,由队列处理 */
getLoaderTask(options: KnowledgeBaseAddItemOptionsNonNullableAttribute): LoaderTask
/** 从知识库中删除特定条目 */
remove(options: { uniqueIds: string[]; base: KnowledgeBaseParams }): Promise<void>
/** 搜索知识库 */
search(options: { search: string; base: KnowledgeBaseParams }): Promise<KnowledgeSearchResult[]>
}

View File

@@ -1,48 +0,0 @@
import path from 'node:path'
import { KnowledgeBaseParams } from '@types'
import { app } from 'electron'
import { EmbedJsFramework } from './EmbedJsFramework'
import { IKnowledgeFramework } from './IKnowledgeFramework'
import { LangChainFramework } from './LangChainFramework'
class KnowledgeFrameworkFactory {
private static instance: KnowledgeFrameworkFactory
private frameworks: Map<string, IKnowledgeFramework> = new Map()
private storageDir: string
private constructor(storageDir: string) {
this.storageDir = storageDir
}
public static getInstance(storageDir: string): KnowledgeFrameworkFactory {
if (!KnowledgeFrameworkFactory.instance) {
KnowledgeFrameworkFactory.instance = new KnowledgeFrameworkFactory(storageDir)
}
return KnowledgeFrameworkFactory.instance
}
public getFramework(base: KnowledgeBaseParams): IKnowledgeFramework {
const frameworkType = base.framework || 'embedjs' // 如果未指定,默认为 embedjs
if (this.frameworks.has(frameworkType)) {
return this.frameworks.get(frameworkType)!
}
let framework: IKnowledgeFramework
switch (frameworkType) {
case 'langchain':
framework = new LangChainFramework(this.storageDir)
break
case 'embedjs':
default:
framework = new EmbedJsFramework(this.storageDir)
break
}
this.frameworks.set(frameworkType, framework)
return framework
}
}
export const knowledgeFrameworkFactory = KnowledgeFrameworkFactory.getInstance(
path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
)

View File

@@ -1,190 +0,0 @@
import * as fs from 'node:fs'
import path from 'node:path'
import { loggerService } from '@logger'
import { preprocessingService } from '@main/knowledge/preprocess/PreprocessingService'
import Reranker from '@main/knowledge/reranker/Reranker'
import { TraceMethod } from '@mcp-trace/trace-core'
import { MB } from '@shared/config/constant'
import { LoaderReturn } from '@shared/config/types'
import { KnowledgeBaseParams, KnowledgeSearchResult } from '@types'
import { app } from 'electron'
import {
KnowledgeBaseAddItemOptions,
LoaderTask,
loaderTaskIntoOfSet,
LoaderTaskItemState,
LoaderTaskOfSet,
QueueTaskItem
} from './IKnowledgeFramework'
import { knowledgeFrameworkFactory } from './KnowledgeFrameworkFactory'
const logger = loggerService.withContext('MainKnowledgeService')
class KnowledgeService {
private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
private workload = 0
private processingItemCount = 0
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
private static MAXIMUM_WORKLOAD = 80 * MB
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
private static ERROR_LOADER_RETURN: LoaderReturn = {
entriesAdded: 0,
uniqueId: '',
uniqueIds: [''],
loaderType: '',
status: 'failed'
}
constructor() {
this.initStorageDir()
}
private initStorageDir = (): void => {
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
}
private maximumLoad() {
return (
this.processingItemCount >= KnowledgeService.MAXIMUM_PROCESSING_ITEM_COUNT ||
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
)
}
private processingQueueHandle() {
const getSubtasksUntilMaximumLoad = (): QueueTaskItem[] => {
const queueTaskList: QueueTaskItem[] = []
that: for (const [task, resolve] of this.knowledgeItemProcessingQueueMappingPromise) {
for (const item of task.loaderTasks) {
if (this.maximumLoad()) {
break that
}
const { state, task: taskPromise, evaluateTaskWorkload } = item
if (state !== LoaderTaskItemState.PENDING) {
continue
}
const { workload } = evaluateTaskWorkload
this.workload += workload
this.processingItemCount += 1
item.state = LoaderTaskItemState.PROCESSING
queueTaskList.push({
taskPromise: () =>
taskPromise().then(() => {
this.workload -= workload
this.processingItemCount -= 1
task.loaderTasks.delete(item)
if (task.loaderTasks.size === 0) {
this.knowledgeItemProcessingQueueMappingPromise.delete(task)
resolve()
}
this.processingQueueHandle()
}),
resolve: () => {},
evaluateTaskWorkload
})
}
}
return queueTaskList
}
const subTasks = getSubtasksUntilMaximumLoad()
if (subTasks.length > 0) {
const subTaskPromises = subTasks.map(({ taskPromise }) => taskPromise())
Promise.all(subTaskPromises).then(() => {
subTasks.forEach(({ resolve }) => resolve())
})
}
}
private appendProcessingQueue(task: LoaderTask): Promise<LoaderReturn> {
return new Promise((resolve) => {
this.knowledgeItemProcessingQueueMappingPromise.set(loaderTaskIntoOfSet(task), () => {
resolve(task.loaderDoneReturn!)
})
})
}
public async create(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> {
logger.info(`Creating knowledge base: ${JSON.stringify(base)}`)
const framework = knowledgeFrameworkFactory.getFramework(base)
await framework.initialize(base)
}
public async reset(_: Electron.IpcMainInvokeEvent, { base }: { base: KnowledgeBaseParams }): Promise<void> {
const framework = knowledgeFrameworkFactory.getFramework(base)
await framework.reset(base)
}
public async delete(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams, id: string): Promise<void> {
logger.info(`Deleting knowledge base: ${JSON.stringify(base)}`)
const framework = knowledgeFrameworkFactory.getFramework(base)
await framework.delete(id)
}
public add = async (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
logger.info(`Adding item to knowledge base: ${JSON.stringify(options)}`)
return new Promise((resolve) => {
const { base, item, forceReload = false, userId = '' } = options
const framework = knowledgeFrameworkFactory.getFramework(base)
const task = framework.getLoaderTask({ base, item, forceReload, userId })
if (task) {
this.appendProcessingQueue(task).then(() => {
resolve(task.loaderDoneReturn!)
})
this.processingQueueHandle()
} else {
resolve({
...KnowledgeService.ERROR_LOADER_RETURN,
message: 'Unsupported item type',
messageSource: 'embedding'
})
}
})
}
public async remove(
_: Electron.IpcMainInvokeEvent,
{ uniqueIds, base }: { uniqueIds: string[]; base: KnowledgeBaseParams }
): Promise<void> {
logger.info(`Removing items from knowledge base: ${JSON.stringify({ uniqueIds, base })}`)
const framework = knowledgeFrameworkFactory.getFramework(base)
await framework.remove({ uniqueIds, base })
}
public async search(
_: Electron.IpcMainInvokeEvent,
{ search, base }: { search: string; base: KnowledgeBaseParams }
): Promise<KnowledgeSearchResult[]> {
logger.info(`Searching knowledge base: ${JSON.stringify({ search, base })}`)
const framework = knowledgeFrameworkFactory.getFramework(base)
return framework.search({ search, base })
}
@TraceMethod({ spanName: 'rerank', tag: 'Knowledge' })
public async rerank(
_: Electron.IpcMainInvokeEvent,
{ search, base, results }: { search: string; base: KnowledgeBaseParams; results: KnowledgeSearchResult[] }
): Promise<KnowledgeSearchResult[]> {
logger.info(`Reranking knowledge base: ${JSON.stringify({ search, base, results })}`)
if (results.length === 0) {
return results
}
return await new Reranker(base).rerank(search, results)
}
public getStorageDir = (): string => {
return this.storageDir
}
public async checkQuota(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams, userId: string): Promise<number> {
return preprocessingService.checkQuota(base, userId)
}
}
export default new KnowledgeService()

View File

@@ -1,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 []
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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 当前要扫描的路径

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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实现
*/

View File

@@ -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
}
/**

View File

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

View File

@@ -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 })
}
// 处理图片内容

View File

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

View File

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

View File

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

View 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') {

View File

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

View File

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

View File

@@ -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?.()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,7 +45,7 @@ export const useTimer = () => {
// 组件卸载时自动清理所有定时器
useEffect(() => {
return () => clearAllTimers()
return clearAllTimers
}, [])
/**

View File

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

View File

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

View File

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

View File

@@ -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 が提供するサービスに依存しており、関連する言語をサポートするには、システムで言語パックをダウンロードする必要があります。"

View File

@@ -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 необходимо загрузить языковой пакет в системе для поддержки соответствующего языка."

View File

@@ -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 提供服务,您需要在系统中下载语言包来支持相关语言。"

View File

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