mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 21:01:32 +08:00
Merge branch 'main' of github.com:CherryHQ/cherry-studio into feat/aisdk-package
This commit is contained in:
commit
9bde8b3cae
@ -55,6 +55,9 @@ files:
|
||||
- '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds
|
||||
- '!node_modules/selection-hook/node_modules' # we don't need what in the node_modules dir
|
||||
- '!node_modules/selection-hook/src' # we don't need source files
|
||||
- '!node_modules/tesseract.js-core/{tesseract-core.js,tesseract-core.wasm,tesseract-core.wasm.js}' # we don't need source files
|
||||
- '!node_modules/tesseract.js-core/{tesseract-core-lstm.js,tesseract-core-lstm.wasm,tesseract-core-lstm.wasm.js}' # we don't need source files
|
||||
- '!node_modules/tesseract.js-core/{tesseract-core-simd-lstm.js,tesseract-core-simd-lstm.wasm,tesseract-core-simd-lstm.wasm.js}' # we don't need source files
|
||||
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}' # filter .node build files
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
|
||||
16
package.json
16
package.json
@ -72,6 +72,7 @@
|
||||
"dependencies": {
|
||||
"@libsql/client": "0.14.0",
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@napi-rs/system-ocr": "^1.0.2",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"jsdom": "26.1.0",
|
||||
@ -123,7 +124,7 @@
|
||||
"@eslint-react/eslint-plugin": "^1.36.1",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@langchain/ollama": "^0.2.1",
|
||||
@ -140,9 +141,9 @@
|
||||
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.9.1",
|
||||
"@shikijs/markdown-it": "^3.12.0",
|
||||
"@swc/plugin-styled-components": "^8.0.4",
|
||||
"@tanstack/react-query": "^5.27.0",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
@ -150,7 +151,6 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
"@types/cli-progress": "^3",
|
||||
"@types/diff": "^7",
|
||||
"@types/fs-extra": "^11",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/markdown-it": "^14",
|
||||
@ -188,7 +188,7 @@
|
||||
"dayjs": "^1.11.11",
|
||||
"dexie": "^4.0.8",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"diff": "^7.0.0",
|
||||
"diff": "^8.0.2",
|
||||
"docx": "^9.0.2",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "37.3.1",
|
||||
@ -218,14 +218,14 @@
|
||||
"isbinaryfile": "5.0.4",
|
||||
"jaison": "^2.0.2",
|
||||
"jest-styled-components": "^7.2.0",
|
||||
"linguist-languages": "^8.0.0",
|
||||
"linguist-languages": "^8.1.0",
|
||||
"lint-staged": "^15.5.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"macos-release": "^3.4.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mermaid": "^11.9.0",
|
||||
"mermaid": "^11.10.1",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.10.5",
|
||||
"notion-helper": "^1.3.22",
|
||||
@ -265,7 +265,7 @@
|
||||
"remove-markdown": "^0.6.2",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "^1.88.0",
|
||||
"shiki": "^3.9.1",
|
||||
"shiki": "^3.12.0",
|
||||
"strict-url-sanitise": "^0.0.1",
|
||||
"string-width": "^7.2.0",
|
||||
"styled-components": "^6.1.11",
|
||||
|
||||
@ -2020,6 +2020,10 @@ export const languages: Record<string, LanguageData> = {
|
||||
extensions: ['.nginx', '.nginxconf', '.vhost'],
|
||||
aliases: ['nginx configuration file']
|
||||
},
|
||||
Nickel: {
|
||||
type: 'programming',
|
||||
extensions: ['.ncl']
|
||||
},
|
||||
Nim: {
|
||||
type: 'programming',
|
||||
extensions: ['.nim', '.nim.cfg', '.nimble', '.nimrod', '.nims']
|
||||
@ -3061,7 +3065,7 @@ export const languages: Record<string, LanguageData> = {
|
||||
},
|
||||
SWIG: {
|
||||
type: 'programming',
|
||||
extensions: ['.i']
|
||||
extensions: ['.i', '.swg', '.swig']
|
||||
},
|
||||
SystemVerilog: {
|
||||
type: 'programming',
|
||||
|
||||
@ -421,7 +421,7 @@ end tell`
|
||||
const envPrefix = buildEnvPrefix(false)
|
||||
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
|
||||
|
||||
const linuxTerminals = ['gnome-terminal', 'konsole', 'xterm', 'x-terminal-emulator']
|
||||
const linuxTerminals = ['gnome-terminal', 'konsole', 'deepin-terminal', 'xterm', 'x-terminal-emulator']
|
||||
let foundTerminal = 'xterm' // Default to xterm
|
||||
|
||||
for (const terminal of linuxTerminals) {
|
||||
@ -448,6 +448,9 @@ end tell`
|
||||
} else if (foundTerminal === 'konsole') {
|
||||
terminalCommand = 'konsole'
|
||||
terminalArgs = ['--workdir', directory, '-e', 'bash', '-c', `clear && ${command}; exec bash`]
|
||||
} else if (foundTerminal === 'deepin-terminal') {
|
||||
terminalCommand = 'deepin-terminal'
|
||||
terminalArgs = ['-w', directory, '-e', 'bash', '-c', `clear && ${command}; exec bash`]
|
||||
} else {
|
||||
// Default to xterm
|
||||
terminalCommand = 'xterm'
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
|
||||
|
||||
import { tesseractService } from './tesseract/TesseractService'
|
||||
import { systemOcrService } from './builtin/SystemOcrService'
|
||||
import { tesseractService } from './builtin/TesseractService'
|
||||
|
||||
const logger = loggerService.withContext('OcrService')
|
||||
|
||||
@ -24,7 +25,7 @@ export class OcrService {
|
||||
if (!handler) {
|
||||
throw new Error(`Provider ${provider.id} is not registered`)
|
||||
}
|
||||
return handler(file)
|
||||
return handler(file, provider.config)
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,3 +33,4 @@ export const ocrService = new OcrService()
|
||||
|
||||
// Register built-in providers
|
||||
ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService))
|
||||
ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
|
||||
|
||||
5
src/main/services/ocr/builtin/OcrBaseService.ts
Normal file
5
src/main/services/ocr/builtin/OcrBaseService.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { OcrHandler } from '@types'
|
||||
|
||||
export abstract class OcrBaseService {
|
||||
abstract ocr: OcrHandler
|
||||
}
|
||||
39
src/main/services/ocr/builtin/SystemOcrService.ts
Normal file
39
src/main/services/ocr/builtin/SystemOcrService.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { isMac, isWin } from '@main/constant'
|
||||
import { loadOcrImage } from '@main/utils/ocr'
|
||||
import { OcrAccuracy, recognize } from '@napi-rs/system-ocr'
|
||||
import {
|
||||
ImageFileMetadata,
|
||||
isImageFileMetadata as isImageFileMetadata,
|
||||
OcrResult,
|
||||
OcrSystemConfig,
|
||||
SupportedOcrFile
|
||||
} from '@types'
|
||||
|
||||
import { OcrBaseService } from './OcrBaseService'
|
||||
|
||||
// const logger = loggerService.withContext('SystemOcrService')
|
||||
export class SystemOcrService extends OcrBaseService {
|
||||
constructor() {
|
||||
super()
|
||||
if (!isWin && !isMac) {
|
||||
throw new Error('System OCR is only supported on Windows and macOS')
|
||||
}
|
||||
}
|
||||
|
||||
private async ocrImage(file: ImageFileMetadata, options?: OcrSystemConfig): Promise<OcrResult> {
|
||||
const buffer = await loadOcrImage(file)
|
||||
const langs = isWin ? options?.langs : undefined
|
||||
const result = await recognize(buffer, OcrAccuracy.Accurate, langs)
|
||||
return { text: result.text }
|
||||
}
|
||||
|
||||
public ocr = async (file: SupportedOcrFile, options?: OcrSystemConfig): Promise<OcrResult> => {
|
||||
if (isImageFileMetadata(file)) {
|
||||
return this.ocrImage(file, options)
|
||||
} else {
|
||||
throw new Error('Unsupported file type, currently only image files are supported')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const systemOcrService = new SystemOcrService()
|
||||
115
src/main/services/ocr/builtin/TesseractService.ts
Normal file
115
src/main/services/ocr/builtin/TesseractService.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { getIpCountry } from '@main/utils/ipService'
|
||||
import { loadOcrImage } from '@main/utils/ocr'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { ImageFileMetadata, isImageFileMetadata, OcrResult, OcrTesseractConfig, SupportedOcrFile } from '@types'
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import { isEqual } from 'lodash'
|
||||
import path from 'path'
|
||||
import Tesseract, { createWorker, LanguageCode } from 'tesseract.js'
|
||||
|
||||
import { OcrBaseService } from './OcrBaseService'
|
||||
|
||||
const logger = loggerService.withContext('TesseractService')
|
||||
|
||||
// config
|
||||
const MB_SIZE_THRESHOLD = 50
|
||||
const defaultLangs = ['chi_sim', 'chi_tra', 'eng'] satisfies LanguageCode[]
|
||||
enum TesseractLangsDownloadUrl {
|
||||
CN = 'https://gitcode.com/beyondkmp/tessdata-best/releases/download/1.0.0/'
|
||||
}
|
||||
|
||||
export class TesseractService extends OcrBaseService {
|
||||
private worker: Tesseract.Worker | null = null
|
||||
private previousLangs: OcrTesseractConfig['langs']
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.previousLangs = {}
|
||||
}
|
||||
|
||||
async getWorker(options?: OcrTesseractConfig): Promise<Tesseract.Worker> {
|
||||
let langsArray: LanguageCode[]
|
||||
if (options?.langs) {
|
||||
// TODO: use type safe objectKeys
|
||||
langsArray = Object.keys(options.langs) as LanguageCode[]
|
||||
if (langsArray.length === 0) {
|
||||
logger.warn('Empty langs option. Fallback to defaultLangs.')
|
||||
langsArray = defaultLangs
|
||||
}
|
||||
} else {
|
||||
langsArray = defaultLangs
|
||||
}
|
||||
logger.debug('langsArray', langsArray)
|
||||
if (!this.worker || !isEqual(this.previousLangs, langsArray)) {
|
||||
if (this.worker) {
|
||||
await this.dispose()
|
||||
}
|
||||
logger.debug('use langsArray to create worker', langsArray)
|
||||
const langPath = await this._getLangPath()
|
||||
const cachePath = await this._getCacheDir()
|
||||
const promise = new Promise<Tesseract.Worker>((resolve, reject) => {
|
||||
createWorker(langsArray, undefined, {
|
||||
langPath,
|
||||
cachePath,
|
||||
logger: (m) => logger.debug('From worker', m),
|
||||
errorHandler: (e) => {
|
||||
logger.error('Worker Error', e)
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
})
|
||||
this.worker = await promise
|
||||
}
|
||||
return this.worker
|
||||
}
|
||||
|
||||
private async imageOcr(file: ImageFileMetadata, options?: OcrTesseractConfig): Promise<OcrResult> {
|
||||
const worker = await this.getWorker(options)
|
||||
const stat = await fs.promises.stat(file.path)
|
||||
if (stat.size > MB_SIZE_THRESHOLD * MB) {
|
||||
throw new Error(`This image is too large (max ${MB_SIZE_THRESHOLD}MB)`)
|
||||
}
|
||||
const buffer = await loadOcrImage(file)
|
||||
const result = await worker.recognize(buffer)
|
||||
return { text: result.data.text }
|
||||
}
|
||||
|
||||
public ocr = async (file: SupportedOcrFile, options?: OcrTesseractConfig): Promise<OcrResult> => {
|
||||
if (!isImageFileMetadata(file)) {
|
||||
throw new Error('Only image files are supported currently')
|
||||
}
|
||||
return this.imageOcr(file, options)
|
||||
}
|
||||
|
||||
private async _getLangPath(): Promise<string> {
|
||||
const country = await getIpCountry()
|
||||
return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : ''
|
||||
}
|
||||
|
||||
private async _getCacheDir(): Promise<string> {
|
||||
const cacheDir = path.join(app.getPath('userData'), 'tesseract')
|
||||
// use access to check if the directory exists
|
||||
if (
|
||||
!(await fs.promises
|
||||
.access(cacheDir, fs.constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false))
|
||||
) {
|
||||
await fs.promises.mkdir(cacheDir, { recursive: true })
|
||||
}
|
||||
return cacheDir
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.terminate()
|
||||
this.worker = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const tesseractService = new TesseractService()
|
||||
@ -1,82 +0,0 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { getIpCountry } from '@main/utils/ipService'
|
||||
import { loadOcrImage } from '@main/utils/ocr'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { ImageFileMetadata, isImageFile, OcrResult, SupportedOcrFile } from '@types'
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import Tesseract, { createWorker, LanguageCode } from 'tesseract.js'
|
||||
|
||||
const logger = loggerService.withContext('TesseractService')
|
||||
|
||||
// config
|
||||
const MB_SIZE_THRESHOLD = 50
|
||||
const tesseractLangs = ['chi_sim', 'chi_tra', 'eng'] satisfies LanguageCode[]
|
||||
enum TesseractLangsDownloadUrl {
|
||||
CN = 'https://gitcode.com/beyondkmp/tessdata/releases/download/4.1.0/',
|
||||
GLOBAL = 'https://github.com/tesseract-ocr/tessdata/raw/main/'
|
||||
}
|
||||
|
||||
export class TesseractService {
|
||||
private worker: Tesseract.Worker | null = null
|
||||
|
||||
async getWorker(): Promise<Tesseract.Worker> {
|
||||
if (!this.worker) {
|
||||
// for now, only support limited languages
|
||||
this.worker = await createWorker(tesseractLangs, undefined, {
|
||||
langPath: await this._getLangPath(),
|
||||
cachePath: await this._getCacheDir(),
|
||||
gzip: false,
|
||||
logger: (m) => logger.debug('From worker', m)
|
||||
})
|
||||
}
|
||||
return this.worker
|
||||
}
|
||||
|
||||
async imageOcr(file: ImageFileMetadata): Promise<OcrResult> {
|
||||
const worker = await this.getWorker()
|
||||
const stat = await fs.promises.stat(file.path)
|
||||
if (stat.size > MB_SIZE_THRESHOLD * MB) {
|
||||
throw new Error(`This image is too large (max ${MB_SIZE_THRESHOLD}MB)`)
|
||||
}
|
||||
const buffer = await loadOcrImage(file)
|
||||
const result = await worker.recognize(buffer)
|
||||
return { text: result.data.text }
|
||||
}
|
||||
|
||||
async ocr(file: SupportedOcrFile): Promise<OcrResult> {
|
||||
if (!isImageFile(file)) {
|
||||
throw new Error('Only image files are supported currently')
|
||||
}
|
||||
return this.imageOcr(file)
|
||||
}
|
||||
|
||||
private async _getLangPath(): Promise<string> {
|
||||
const country = await getIpCountry()
|
||||
return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : TesseractLangsDownloadUrl.GLOBAL
|
||||
}
|
||||
|
||||
private async _getCacheDir(): Promise<string> {
|
||||
const cacheDir = path.join(app.getPath('userData'), 'tesseract')
|
||||
// use access to check if the directory exists
|
||||
if (
|
||||
!(await fs.promises
|
||||
.access(cacheDir, fs.constants.F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false))
|
||||
) {
|
||||
await fs.promises.mkdir(cacheDir, { recursive: true })
|
||||
}
|
||||
return cacheDir
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.terminate()
|
||||
this.worker = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const tesseractService = new TesseractService()
|
||||
@ -2,11 +2,12 @@ import { ImageFileMetadata } from '@types'
|
||||
import { readFile } from 'fs/promises'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const preprocessImage = async (buffer: Buffer) => {
|
||||
return await sharp(buffer)
|
||||
const preprocessImage = async (buffer: Buffer): Promise<Buffer> => {
|
||||
return sharp(buffer)
|
||||
.grayscale() // 转为灰度
|
||||
.normalize()
|
||||
.sharpen()
|
||||
.png({ quality: 100 })
|
||||
.toBuffer()
|
||||
}
|
||||
|
||||
@ -23,5 +24,5 @@ const preprocessImage = async (buffer: Buffer) => {
|
||||
*/
|
||||
export const loadOcrImage = async (file: ImageFileMetadata): Promise<Buffer> => {
|
||||
const buffer = await readFile(file.path)
|
||||
return await preprocessImage(buffer)
|
||||
return preprocessImage(buffer)
|
||||
}
|
||||
|
||||
@ -46,6 +46,7 @@ import {
|
||||
EFFORT_RATIO,
|
||||
FileTypes,
|
||||
isSystemProvider,
|
||||
isTranslateAssistant,
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
MCPToolResponse,
|
||||
@ -54,7 +55,6 @@ import {
|
||||
Provider,
|
||||
SystemProviderIds,
|
||||
ToolCallResponse,
|
||||
TranslateAssistant,
|
||||
WebSearchSource
|
||||
} from '@renderer/types'
|
||||
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
|
||||
@ -569,13 +569,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
const extra_body: Record<string, any> = {}
|
||||
|
||||
if (isQwenMTModel(model)) {
|
||||
const targetLanguage = (assistant as TranslateAssistant).targetLanguage
|
||||
extra_body.translation_options = {
|
||||
source_lang: 'auto',
|
||||
target_lang: mapLanguageToQwenMTModel(targetLanguage!)
|
||||
}
|
||||
if (!extra_body.translation_options.target_lang) {
|
||||
throw new Error(t('translate.error.not_supported', { language: targetLanguage?.value }))
|
||||
if (isTranslateAssistant(assistant)) {
|
||||
const targetLanguage = assistant.targetLanguage
|
||||
const translationOptions = {
|
||||
source_lang: 'auto',
|
||||
target_lang: mapLanguageToQwenMTModel(targetLanguage)
|
||||
} as const
|
||||
if (!translationOptions.target_lang) {
|
||||
throw new Error(t('translate.error.not_supported', { language: targetLanguage.value }))
|
||||
}
|
||||
extra_body.translation_options = translationOptions
|
||||
} else {
|
||||
throw new Error(t('translate.error.chat_qwen_mt'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { CodeOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats'
|
||||
import { Button } from 'antd'
|
||||
import { Code, DownloadIcon, Globe, LinkIcon, Sparkles } from 'lucide-react'
|
||||
import { FC, useState } from 'react'
|
||||
@ -28,7 +28,7 @@ const getTerminalStyles = (theme: ThemeMode) => ({
|
||||
|
||||
const HtmlArtifactsCard: FC<Props> = ({ html, onSave, isStreaming = false }) => {
|
||||
const { t } = useTranslation()
|
||||
const title = extractTitle(html) || 'HTML Artifacts'
|
||||
const title = extractHtmlTitle(html) || 'HTML Artifacts'
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
@ -48,7 +48,7 @@ const HtmlArtifactsCard: FC<Props> = ({ html, onSave, isStreaming = false }) =>
|
||||
}
|
||||
|
||||
const handleDownload = async () => {
|
||||
const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html`
|
||||
const fileName = `${getFileNameFromHtmlTitle(title) || 'html-artifact'}.html`
|
||||
await window.api.file.save(fileName, htmlContent)
|
||||
window.message.success({ content: t('message.download.success'), key: 'download' })
|
||||
}
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
|
||||
import { CopyIcon, FilePngIcon } from '@renderer/components/Icons'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Button, Modal, Splitter, Tooltip, Typography } from 'antd'
|
||||
import { Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats'
|
||||
import { captureScrollableIframeAsBlob, captureScrollableIframeAsDataURL } from '@renderer/utils/image'
|
||||
import { Button, Dropdown, Modal, Splitter, Tooltip, Typography } from 'antd'
|
||||
import { Camera, Check, Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -21,7 +25,9 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
const { t } = useTranslation()
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [saved, setSaved] = useTemporaryValue(false, 2000)
|
||||
const codeEditorRef = useRef<CodeEditorHandles>(null)
|
||||
const previewFrameRef = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
// Prevent body scroll when fullscreen
|
||||
useEffect(() => {
|
||||
@ -38,8 +44,32 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
|
||||
const handleSave = () => {
|
||||
codeEditorRef.current?.save?.()
|
||||
setSaved(true)
|
||||
}
|
||||
|
||||
const handleCapture = useCallback(
|
||||
async (to: 'file' | 'clipboard') => {
|
||||
const title = extractHtmlTitle(html)
|
||||
const fileName = getFileNameFromHtmlTitle(title) || 'html-artifact'
|
||||
|
||||
if (to === 'file') {
|
||||
const dataUrl = await captureScrollableIframeAsDataURL(previewFrameRef)
|
||||
if (dataUrl) {
|
||||
window.api.file.saveImage(fileName, dataUrl)
|
||||
}
|
||||
}
|
||||
if (to === 'clipboard') {
|
||||
await captureScrollableIframeAsBlob(previewFrameRef, async (blob) => {
|
||||
if (blob) {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
window.message.success(t('message.copy.success'))
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
[html, t]
|
||||
)
|
||||
|
||||
const renderHeader = () => (
|
||||
<ModalHeader onDoubleClick={() => setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}>
|
||||
<HeaderLeft $isFullscreen={isFullscreen}>
|
||||
@ -47,7 +77,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
</HeaderLeft>
|
||||
|
||||
<HeaderCenter>
|
||||
<ViewControls>
|
||||
<ViewControls onDoubleClick={(e) => e.stopPropagation()}>
|
||||
<ViewButton
|
||||
size="small"
|
||||
type={viewMode === 'split' ? 'primary' : 'default'}
|
||||
@ -72,7 +102,29 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
</ViewControls>
|
||||
</HeaderCenter>
|
||||
|
||||
<HeaderRight $isFullscreen={isFullscreen}>
|
||||
<HeaderRight $isFullscreen={isFullscreen} onDoubleClick={(e) => e.stopPropagation()}>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: t('html_artifacts.capture.to_file'),
|
||||
key: 'capture_to_file',
|
||||
icon: <FilePngIcon size={14} className="lucide-custom" />,
|
||||
onClick: () => handleCapture('file')
|
||||
},
|
||||
{
|
||||
label: t('html_artifacts.capture.to_clipboard'),
|
||||
key: 'capture_to_clipboard',
|
||||
icon: <CopyIcon size={14} className="lucide-custom" />,
|
||||
onClick: () => handleCapture('clipboard')
|
||||
}
|
||||
]
|
||||
}}>
|
||||
<Tooltip title={t('html_artifacts.capture.label')} mouseLeaveDelay={0}>
|
||||
<Button type="text" icon={<Camera size={16} />} className="nodrag" />
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
<Button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
type="text"
|
||||
@ -104,10 +156,16 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
/>
|
||||
<ToolbarWrapper>
|
||||
<Tooltip title={t('code_block.edit.save.label')} mouseLeaveDelay={0}>
|
||||
<Button
|
||||
<ToolbarButton
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<SaveIcon size={16} className="custom-lucide" />}
|
||||
icon={
|
||||
saved ? (
|
||||
<Check size={16} color="var(--color-status-success)" />
|
||||
) : (
|
||||
<SaveIcon size={16} className="custom-lucide" />
|
||||
)
|
||||
}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
</Tooltip>
|
||||
@ -119,6 +177,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
<PreviewSection>
|
||||
{html.trim() ? (
|
||||
<PreviewFrame
|
||||
ref={previewFrameRef}
|
||||
key={html} // Force recreate iframe when preview content changes
|
||||
srcDoc={html}
|
||||
title="HTML Preview"
|
||||
@ -373,4 +432,12 @@ const ToolbarWrapper = styled.div`
|
||||
z-index: 1;
|
||||
`
|
||||
|
||||
const ToolbarButton = styled(Button)`
|
||||
border: none;
|
||||
box-shadow:
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
`
|
||||
|
||||
export default HtmlArtifactsPopup
|
||||
|
||||
@ -19,7 +19,7 @@ import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { pyodideService } from '@renderer/services/PyodideService'
|
||||
import { getExtensionByLanguage } from '@renderer/utils/code-language'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { extractHtmlTitle } from '@renderer/utils/formats'
|
||||
import dayjs from 'dayjs'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -136,7 +136,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
|
||||
// 尝试提取 HTML 标题
|
||||
if (language === 'html' && children.includes('</html>')) {
|
||||
fileName = extractTitle(children) || ''
|
||||
fileName = extractHtmlTitle(children) || ''
|
||||
}
|
||||
|
||||
// 默认使用日期格式命名
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { CSSProperties, SVGProps } from 'react'
|
||||
|
||||
interface BaseFileIconProps extends SVGProps<SVGSVGElement> {
|
||||
size?: string
|
||||
size?: string | number
|
||||
text?: string
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import {
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FileImageOutlined,
|
||||
RotateLeftOutlined,
|
||||
RotateRightOutlined,
|
||||
SwapOutlined,
|
||||
@ -13,11 +12,14 @@ import { loggerService } from '@logger'
|
||||
import { download } from '@renderer/utils/download'
|
||||
import { Dropdown, Image as AntImage, ImageProps as AntImageProps, Space } from 'antd'
|
||||
import { Base64 } from 'js-base64'
|
||||
import { DownloadIcon, ImageIcon } from 'lucide-react'
|
||||
import mime from 'mime'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { CopyIcon } from './Icons'
|
||||
|
||||
interface ImageViewerProps extends AntImageProps {
|
||||
src: string
|
||||
}
|
||||
@ -33,7 +35,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
|
||||
if (src.startsWith('data:')) {
|
||||
// 处理 base64 格式的图片
|
||||
const match = src.match(/^data:(image\/\w+);base64,(.+)$/)
|
||||
if (!match) throw new Error('无效的 base64 图片格式')
|
||||
if (!match) throw new Error('Invalid base64 image format')
|
||||
const mimeType = match[1]
|
||||
const byteArray = Base64.toUint8Array(match[2])
|
||||
const blob = new Blob([byteArray], { type: mimeType })
|
||||
@ -62,17 +64,17 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
|
||||
|
||||
window.message.success(t('message.copy.success'))
|
||||
} catch (error) {
|
||||
logger.error('复制图片失败:', error as Error)
|
||||
logger.error('Failed to copy image:', error as Error)
|
||||
window.message.error(t('message.copy.failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const getContextMenuItems = (src: string) => {
|
||||
const getContextMenuItems = (src: string, size: number = 14) => {
|
||||
return [
|
||||
{
|
||||
key: 'copy-url',
|
||||
label: t('common.copy'),
|
||||
icon: <CopyOutlined />,
|
||||
icon: <CopyIcon size={size} />,
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(src)
|
||||
window.message.success(t('message.copy.success'))
|
||||
@ -81,13 +83,13 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
|
||||
{
|
||||
key: 'download',
|
||||
label: t('common.download'),
|
||||
icon: <DownloadOutlined />,
|
||||
icon: <DownloadIcon size={size} />,
|
||||
onClick: () => download(src)
|
||||
},
|
||||
{
|
||||
key: 'copy-image',
|
||||
label: t('preview.copy.image'),
|
||||
icon: <FileImageOutlined />,
|
||||
icon: <ImageIcon size={size} />,
|
||||
onClick: () => handleCopyImage(src)
|
||||
}
|
||||
]
|
||||
@ -98,6 +100,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
|
||||
<AntImage
|
||||
src={src}
|
||||
style={style}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
preview={{
|
||||
mask: typeof props.preview === 'object' ? props.preview.mask : false,
|
||||
|
||||
20
src/renderer/src/components/InfoPopover.tsx
Normal file
20
src/renderer/src/components/InfoPopover.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Popover, PopoverProps } from 'antd'
|
||||
import { Info } from 'lucide-react'
|
||||
|
||||
type InheritedPopoverProps = Omit<PopoverProps, 'children'>
|
||||
|
||||
interface InfoPopoverProps extends InheritedPopoverProps {
|
||||
iconColor?: string
|
||||
iconSize?: string | number
|
||||
iconStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
const InfoPopover = ({ iconColor = 'var(--color-text-3)', iconSize = 14, iconStyle, ...rest }: InfoPopoverProps) => {
|
||||
return (
|
||||
<Popover {...rest}>
|
||||
<Info size={iconSize} color={iconColor} style={{ ...iconStyle }} role="img" aria-label="Information" />
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default InfoPopover
|
||||
@ -1,5 +1,5 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { getProgressLabel } from '@renderer/i18n/label'
|
||||
import { getBackupProgressLabel } from '@renderer/i18n/label'
|
||||
import { backup } from '@renderer/services/BackupService'
|
||||
import store from '@renderer/store'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
@ -61,7 +61,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
progress: Math.floor(progressData.progress)
|
||||
})
|
||||
}
|
||||
return getProgressLabel(progressData.stage)
|
||||
return getBackupProgressLabel(progressData.stage)
|
||||
}
|
||||
|
||||
BackupPopup.hide = onCancel
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { getProgressLabel } from '@renderer/i18n/label'
|
||||
import { getRestoreProgressLabel } from '@renderer/i18n/label'
|
||||
import { restore } from '@renderer/services/BackupService'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Modal, Progress } from 'antd'
|
||||
@ -49,11 +49,11 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
if (!progressData) return ''
|
||||
|
||||
if (progressData.stage === 'copying_files') {
|
||||
return t('backup.progress.copying_files', {
|
||||
return t('restore.progress.copying_files', {
|
||||
progress: Math.floor(progressData.progress)
|
||||
})
|
||||
}
|
||||
return getProgressLabel(progressData.stage)
|
||||
return getRestoreProgressLabel(progressData.stage)
|
||||
}
|
||||
|
||||
RestorePopup.hide = onCancel
|
||||
|
||||
@ -17,7 +17,8 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
|
||||
|
||||
// Sanitize the SVG content
|
||||
const sanitizedContent = DOMPurify.sanitize(svgContent, {
|
||||
ADD_TAGS: ['foreignObject']
|
||||
ADD_TAGS: ['animate', 'foreignObject', 'use'],
|
||||
ADD_ATTR: ['from', 'to']
|
||||
})
|
||||
|
||||
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
|
||||
|
||||
16
src/renderer/src/components/Tags/ErrorTag.tsx
Normal file
16
src/renderer/src/components/Tags/ErrorTag.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { CircleXIcon } from 'lucide-react'
|
||||
|
||||
import CustomTag from './CustomTag'
|
||||
|
||||
type Props = {
|
||||
iconSize?: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export const ErrorTag = ({ iconSize: size = 14, message }: Props) => {
|
||||
return (
|
||||
<CustomTag icon={<CircleXIcon size={size} color="var(--color-status-error)" />} color="var(--color-status-error)">
|
||||
{message}
|
||||
</CustomTag>
|
||||
)
|
||||
}
|
||||
16
src/renderer/src/components/Tags/SuccessTag.tsx
Normal file
16
src/renderer/src/components/Tags/SuccessTag.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
|
||||
import CustomTag from './CustomTag'
|
||||
|
||||
type Props = {
|
||||
iconSize?: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export const SuccessTag = ({ iconSize: size = 14, message }: Props) => {
|
||||
return (
|
||||
<CustomTag icon={<CheckIcon size={size} color="var(--color-status-success)" />} color="var(--color-status-success)">
|
||||
{message}
|
||||
</CustomTag>
|
||||
)
|
||||
}
|
||||
18
src/renderer/src/components/Tags/WarnTag.tsx
Normal file
18
src/renderer/src/components/Tags/WarnTag.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { AlertTriangleIcon } from 'lucide-react'
|
||||
|
||||
import CustomTag from './CustomTag'
|
||||
|
||||
type Props = {
|
||||
iconSize?: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export const WarnTag = ({ iconSize: size = 14, message }: Props) => {
|
||||
return (
|
||||
<CustomTag
|
||||
icon={<AlertTriangleIcon size={size} color="var(--color-status-warning)" />}
|
||||
color="var(--color-status-warning)">
|
||||
{message}
|
||||
</CustomTag>
|
||||
)
|
||||
}
|
||||
@ -416,6 +416,7 @@ export function getModelLogo(modelId: string) {
|
||||
jina: isLight ? JinaModelLogo : JinaModelLogoDark,
|
||||
abab: isLight ? MinimaxModelLogo : MinimaxModelLogoDark,
|
||||
minimax: isLight ? MinimaxModelLogo : MinimaxModelLogoDark,
|
||||
veo: isLight ? GeminiModelLogo : GeminiModelLogoDark,
|
||||
o1: isLight ? ChatGPTo1ModelLogo : ChatGPTo1ModelLogoDark,
|
||||
o3: isLight ? ChatGPTo1ModelLogo : ChatGPTo1ModelLogoDark,
|
||||
o4: isLight ? ChatGPTo1ModelLogo : ChatGPTo1ModelLogoDark,
|
||||
@ -430,7 +431,7 @@ export function getModelLogo(modelId: string) {
|
||||
'gpt-oss(?:-[\\w-]+)': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
|
||||
'text-moderation': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
|
||||
'babbage-': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
|
||||
'sora-': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
|
||||
'(sora-|sora_)': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
|
||||
'(^|/)omni-': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
|
||||
'Embedding-V1': isLight ? WenxinModelLogo : WenxinModelLogoDark,
|
||||
'text-embedding-v': isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
@ -458,6 +459,7 @@ export function getModelLogo(modelId: string) {
|
||||
step: isLight ? StepModelLogo : StepModelLogoDark,
|
||||
hailuo: isLight ? HailuoModelLogo : HailuoModelLogoDark,
|
||||
doubao: isLight ? DoubaoModelLogo : DoubaoModelLogoDark,
|
||||
seedream: isLight ? DoubaoModelLogo : DoubaoModelLogoDark,
|
||||
'ep-202': isLight ? DoubaoModelLogo : DoubaoModelLogoDark,
|
||||
cohere: isLight ? CohereModelLogo : CohereModelLogoDark,
|
||||
command: isLight ? CohereModelLogo : CohereModelLogoDark,
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import {
|
||||
BuiltinOcrProvider,
|
||||
BuiltinOcrProviderId,
|
||||
ImageOcrProvider,
|
||||
OcrProviderCapability,
|
||||
OcrTesseractProvider
|
||||
OcrSystemProvider,
|
||||
OcrTesseractProvider,
|
||||
TesseractLangCode,
|
||||
TranslateLanguageCode
|
||||
} from '@renderer/types'
|
||||
|
||||
const tesseract: BuiltinOcrProvider & ImageOcrProvider & OcrTesseractProvider = {
|
||||
import { isMac, isWin } from './constant'
|
||||
|
||||
const tesseract: OcrTesseractProvider = {
|
||||
id: 'tesseract',
|
||||
name: 'Tesseract',
|
||||
capabilities: {
|
||||
@ -19,14 +23,132 @@ const tesseract: BuiltinOcrProvider & ImageOcrProvider & OcrTesseractProvider =
|
||||
eng: true
|
||||
}
|
||||
}
|
||||
} as const satisfies OcrTesseractProvider
|
||||
} as const
|
||||
|
||||
const systemOcr: OcrSystemProvider = {
|
||||
id: 'system',
|
||||
name: 'System',
|
||||
config: {
|
||||
langs: isWin ? ['en-us'] : undefined
|
||||
},
|
||||
capabilities: {
|
||||
image: true
|
||||
// pdf: true
|
||||
}
|
||||
} as const satisfies OcrSystemProvider
|
||||
|
||||
export const BUILTIN_OCR_PROVIDERS_MAP = {
|
||||
tesseract
|
||||
tesseract,
|
||||
system: systemOcr
|
||||
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
|
||||
|
||||
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)
|
||||
|
||||
export const DEFAULT_OCR_PROVIDER = {
|
||||
image: tesseract
|
||||
image: isWin || isMac ? systemOcr : tesseract
|
||||
} as const satisfies Record<OcrProviderCapability, BuiltinOcrProvider>
|
||||
|
||||
export const TESSERACT_LANG_MAP: Record<TranslateLanguageCode, TesseractLangCode> = {
|
||||
'af-za': 'afr',
|
||||
'am-et': 'amh',
|
||||
'ar-sa': 'ara',
|
||||
'as-in': 'asm',
|
||||
'az-az': 'aze',
|
||||
'az-cyrl-az': 'aze_cyrl',
|
||||
'be-by': 'bel',
|
||||
'bn-bd': 'ben',
|
||||
'bo-cn': 'bod',
|
||||
'bs-ba': 'bos',
|
||||
'bg-bg': 'bul',
|
||||
'ca-es': 'cat',
|
||||
'ceb-ph': 'ceb',
|
||||
'cs-cz': 'ces',
|
||||
'zh-cn': 'chi_sim',
|
||||
'zh-tw': 'chi_tra',
|
||||
'chr-us': 'chr',
|
||||
'cy-gb': 'cym',
|
||||
'da-dk': 'dan',
|
||||
'de-de': 'deu',
|
||||
'dz-bt': 'dzo',
|
||||
'el-gr': 'ell',
|
||||
'en-us': 'eng',
|
||||
'enm-gb': 'enm',
|
||||
'eo-world': 'epo',
|
||||
'et-ee': 'est',
|
||||
'eu-es': 'eus',
|
||||
'fa-ir': 'fas',
|
||||
'fi-fi': 'fin',
|
||||
'fr-fr': 'fra',
|
||||
'frk-de': 'frk',
|
||||
'frm-fr': 'frm',
|
||||
'ga-ie': 'gle',
|
||||
'gl-es': 'glg',
|
||||
'grc-gr': 'grc',
|
||||
'gu-in': 'guj',
|
||||
'ht-ht': 'hat',
|
||||
'he-il': 'heb',
|
||||
'hi-in': 'hin',
|
||||
'hr-hr': 'hrv',
|
||||
'hu-hu': 'hun',
|
||||
'iu-ca': 'iku',
|
||||
'id-id': 'ind',
|
||||
'is-is': 'isl',
|
||||
'it-it': 'ita',
|
||||
'ita-it': 'ita_old',
|
||||
'jv-id': 'jav',
|
||||
'ja-jp': 'jpn',
|
||||
'kn-in': 'kan',
|
||||
'ka-ge': 'kat',
|
||||
'kat-ge': 'kat_old',
|
||||
'kk-kz': 'kaz',
|
||||
'km-kh': 'khm',
|
||||
'ky-kg': 'kir',
|
||||
'ko-kr': 'kor',
|
||||
'ku-tr': 'kur',
|
||||
'la-la': 'lao',
|
||||
'la-va': 'lat',
|
||||
'lv-lv': 'lav',
|
||||
'lt-lt': 'lit',
|
||||
'ml-in': 'mal',
|
||||
'mr-in': 'mar',
|
||||
'mk-mk': 'mkd',
|
||||
'mt-mt': 'mlt',
|
||||
'ms-my': 'msa',
|
||||
'my-mm': 'mya',
|
||||
'ne-np': 'nep',
|
||||
'nl-nl': 'nld',
|
||||
'no-no': 'nor',
|
||||
'or-in': 'ori',
|
||||
'pa-in': 'pan',
|
||||
'pl-pl': 'pol',
|
||||
'pt-pt': 'por',
|
||||
'ps-af': 'pus',
|
||||
'ro-ro': 'ron',
|
||||
'ru-ru': 'rus',
|
||||
'sa-in': 'san',
|
||||
'si-lk': 'sin',
|
||||
'sk-sk': 'slk',
|
||||
'sl-si': 'slv',
|
||||
'es-es': 'spa',
|
||||
'spa-es': 'spa_old',
|
||||
'sq-al': 'sqi',
|
||||
'sr-rs': 'srp',
|
||||
'sr-latn-rs': 'srp_latn',
|
||||
'sw-tz': 'swa',
|
||||
'sv-se': 'swe',
|
||||
'syr-sy': 'syr',
|
||||
'ta-in': 'tam',
|
||||
'te-in': 'tel',
|
||||
'tg-tj': 'tgk',
|
||||
'tl-ph': 'tgl',
|
||||
'th-th': 'tha',
|
||||
'ti-er': 'tir',
|
||||
'tr-tr': 'tur',
|
||||
'ug-cn': 'uig',
|
||||
'uk-ua': 'ukr',
|
||||
'ur-pk': 'urd',
|
||||
'uz-uz': 'uzb',
|
||||
'uz-cyrl-uz': 'uzb_cyrl',
|
||||
'vi-vn': 'vie',
|
||||
'yi-us': 'yid'
|
||||
}
|
||||
|
||||
@ -88,7 +88,7 @@ export function useAssistant(id: string) {
|
||||
const settingsRef = useRef(assistant?.settings)
|
||||
|
||||
useEffect(() => {
|
||||
settingsRef.current = assistant.settings
|
||||
settingsRef.current = assistant?.settings
|
||||
}, [assistant?.settings])
|
||||
|
||||
const updateAssistantSettings = useCallback(
|
||||
|
||||
@ -39,5 +39,5 @@ export const useDrag = <T extends HTMLElement>(onDrop?: (e: React.DragEvent<T>)
|
||||
[onDrop]
|
||||
)
|
||||
|
||||
return { isDragging, handleDragOver, handleDragEnter, handleDragLeave, handleDrop }
|
||||
return { isDragging, setIsDragging, handleDragOver, handleDragEnter, handleDragLeave, handleDrop }
|
||||
}
|
||||
|
||||
@ -1,16 +1,18 @@
|
||||
import { loggerService } from '@logger'
|
||||
import * as OcrService from '@renderer/services/ocr/OcrService'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { ImageFileMetadata, isImageFile, SupportedOcrFile } from '@renderer/types'
|
||||
import { ImageFileMetadata, isImageFileMetadata, SupportedOcrFile } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useOcrProviders } from './useOcrProvider'
|
||||
|
||||
const logger = loggerService.withContext('useOcr')
|
||||
|
||||
export const useOcr = () => {
|
||||
const { t } = useTranslation()
|
||||
const imageProvider = useAppSelector((state) => state.ocr.imageProvider)
|
||||
const { imageProvider } = useOcrProviders()
|
||||
|
||||
/**
|
||||
* 对图片文件进行OCR识别
|
||||
@ -18,9 +20,13 @@ export const useOcr = () => {
|
||||
* @returns OCR识别结果的Promise
|
||||
* @throws OCR失败时抛出错误
|
||||
*/
|
||||
const ocrImage = async (image: ImageFileMetadata) => {
|
||||
return OcrService.ocr(image, imageProvider)
|
||||
}
|
||||
const ocrImage = useCallback(
|
||||
async (image: ImageFileMetadata) => {
|
||||
logger.debug('ocrImage', { config: imageProvider.config })
|
||||
return OcrService.ocr(image, imageProvider)
|
||||
},
|
||||
[imageProvider]
|
||||
)
|
||||
|
||||
/**
|
||||
* 对支持的文件进行OCR识别.
|
||||
@ -33,7 +39,7 @@ export const useOcr = () => {
|
||||
window.message.loading({ content: t('ocr.processing'), key, duration: 0 })
|
||||
// await to keep show loading message
|
||||
try {
|
||||
if (isImageFile(file)) {
|
||||
if (isImageFileMetadata(file)) {
|
||||
return await ocrImage(file)
|
||||
} else {
|
||||
// @ts-expect-error all types should be covered
|
||||
|
||||
@ -1,84 +0,0 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { BUILTIN_OCR_PROVIDERS_MAP } from '@renderer/config/ocr'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { addOcrProvider, removeOcrProvider, updateOcrProviderConfig } from '@renderer/store/ocr'
|
||||
import { isBuiltinOcrProviderId, OcrProvider, OcrProviderConfig } from '@renderer/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
const logger = loggerService.withContext('useOcrProvider')
|
||||
|
||||
export const useOcrProviders = () => {
|
||||
const providers = useAppSelector((state) => state.ocr.providers)
|
||||
const dispatch = useDispatch()
|
||||
const { t } = useTranslation()
|
||||
|
||||
/**
|
||||
* 添加一个新的OCR服务提供者
|
||||
* @param provider - OCR提供者对象,包含id和其他配置信息
|
||||
* @throws {Error} 当尝试添加一个已存在ID的提供者时抛出错误
|
||||
*/
|
||||
const addProvider = (provider: OcrProvider) => {
|
||||
if (providers.some((p) => p.id === provider.id)) {
|
||||
const msg = `Provider with id ${provider.id} already exists`
|
||||
logger.error(msg)
|
||||
window.message.error(t('ocr.error.provider.existing'))
|
||||
throw new Error(msg)
|
||||
}
|
||||
dispatch(addOcrProvider(provider))
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除一个OCR服务提供者
|
||||
* @param id - 要移除的OCR提供者ID
|
||||
* @throws {Error} 当尝试移除一个内置提供商时抛出错误
|
||||
*/
|
||||
const removeProvider = (id: string) => {
|
||||
if (isBuiltinOcrProviderId(id)) {
|
||||
const msg = `Cannot remove builtin provider ${id}`
|
||||
logger.error(msg)
|
||||
window.message.error(t('ocr.error.provider.cannot_remove_builtin'))
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
dispatch(removeOcrProvider(id))
|
||||
}
|
||||
|
||||
return { providers, addProvider, removeProvider }
|
||||
}
|
||||
|
||||
export const useOcrProvider = (id: string) => {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useDispatch()
|
||||
const { providers, addProvider } = useOcrProviders()
|
||||
let provider = providers.find((p) => p.id === id)
|
||||
|
||||
// safely fallback
|
||||
if (!provider) {
|
||||
logger.error(`Ocr Provider ${id} not found`)
|
||||
window.message.error(t('ocr.error.provider.not_found'))
|
||||
if (isBuiltinOcrProviderId(id)) {
|
||||
try {
|
||||
addProvider(BUILTIN_OCR_PROVIDERS_MAP[id])
|
||||
} catch (e) {
|
||||
logger.warn(`Add ${BUILTIN_OCR_PROVIDERS_MAP[id].name} failed. Just use temp provider from config.`)
|
||||
window.message.warning(t('ocr.warning.provider.fallback', { name: BUILTIN_OCR_PROVIDERS_MAP[id].name }))
|
||||
} finally {
|
||||
provider = BUILTIN_OCR_PROVIDERS_MAP[id]
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Fallback to tesseract`)
|
||||
window.message.warning(t('ocr.warning.provider.fallback', { name: 'Tesseract' }))
|
||||
provider = BUILTIN_OCR_PROVIDERS_MAP.tesseract
|
||||
}
|
||||
}
|
||||
|
||||
const updateConfig = (update: Partial<OcrProviderConfig>) => {
|
||||
dispatch(updateOcrProviderConfig({ id: provider.id, update }))
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
updateConfig
|
||||
}
|
||||
}
|
||||
148
src/renderer/src/hooks/useOcrProvider.tsx
Normal file
148
src/renderer/src/hooks/useOcrProvider.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { loggerService } from '@logger'
|
||||
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'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { addOcrProvider, removeOcrProvider, setImageOcrProviderId, updateOcrProviderConfig } from '@renderer/store/ocr'
|
||||
import {
|
||||
ImageOcrProvider,
|
||||
isBuiltinOcrProvider,
|
||||
isBuiltinOcrProviderId,
|
||||
isImageOcrProvider,
|
||||
OcrProvider,
|
||||
OcrProviderConfig
|
||||
} from '@renderer/types'
|
||||
import { Avatar } from 'antd'
|
||||
import { FileQuestionMarkIcon, MonitorIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
const logger = loggerService.withContext('useOcrProvider')
|
||||
|
||||
export const useOcrProviders = () => {
|
||||
const providers = useAppSelector((state) => state.ocr.providers)
|
||||
const imageProviders = providers.filter(isImageOcrProvider)
|
||||
const imageProviderId = useAppSelector((state) => state.ocr.imageProviderId)
|
||||
const [imageProvider, setImageProvider] = useState<ImageOcrProvider>(DEFAULT_OCR_PROVIDER.image)
|
||||
const dispatch = useDispatch()
|
||||
const { t } = useTranslation()
|
||||
|
||||
/**
|
||||
* 添加一个新的OCR服务提供者
|
||||
* @param provider - OCR提供者对象,包含id和其他配置信息
|
||||
* @throws {Error} 当尝试添加一个已存在ID的提供者时抛出错误
|
||||
*/
|
||||
const addProvider = useCallback(
|
||||
(provider: OcrProvider) => {
|
||||
if (providers.some((p) => p.id === provider.id)) {
|
||||
const msg = `Provider with id ${provider.id} already exists`
|
||||
logger.error(msg)
|
||||
window.message.error(t('ocr.error.provider.existing'))
|
||||
throw new Error(msg)
|
||||
}
|
||||
dispatch(addOcrProvider(provider))
|
||||
},
|
||||
[dispatch, providers, t]
|
||||
)
|
||||
|
||||
/**
|
||||
* 移除一个OCR服务提供者
|
||||
* @param id - 要移除的OCR提供者ID
|
||||
* @throws {Error} 当尝试移除一个内置提供商时抛出错误
|
||||
*/
|
||||
const removeProvider = (id: string) => {
|
||||
if (isBuiltinOcrProviderId(id)) {
|
||||
const msg = `Cannot remove builtin provider ${id}`
|
||||
logger.error(msg)
|
||||
window.message.error(t('ocr.error.provider.cannot_remove_builtin'))
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
dispatch(removeOcrProvider(id))
|
||||
}
|
||||
|
||||
const setImageProviderId = useCallback(
|
||||
(id: string) => {
|
||||
dispatch(setImageOcrProviderId(id))
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
const getOcrProviderName = (p: OcrProvider) => {
|
||||
return isBuiltinOcrProvider(p) ? getBuiltinOcrProviderLabel(p.id) : p.name
|
||||
}
|
||||
|
||||
const OcrProviderLogo = ({ provider: p, size = 14 }: { provider: OcrProvider; size?: number }) => {
|
||||
if (isBuiltinOcrProvider(p)) {
|
||||
switch (p.id) {
|
||||
case 'tesseract':
|
||||
return <Avatar size={size} src={TesseractLogo} />
|
||||
case 'system':
|
||||
return <MonitorIcon size={size} />
|
||||
}
|
||||
}
|
||||
return <FileQuestionMarkIcon size={size} />
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const actualImageProvider = imageProviders.find((p) => p.id === imageProviderId)
|
||||
if (!actualImageProvider) {
|
||||
if (isBuiltinOcrProviderId(imageProviderId)) {
|
||||
logger.warn(`Builtin ocr provider ${imageProviderId} not exist. Will add it to providers.`)
|
||||
addProvider(BUILTIN_OCR_PROVIDERS_MAP[imageProviderId])
|
||||
}
|
||||
setImageProviderId(DEFAULT_OCR_PROVIDER.image.id)
|
||||
setImageProvider(DEFAULT_OCR_PROVIDER.image)
|
||||
} else {
|
||||
setImageProviderId(actualImageProvider.id)
|
||||
setImageProvider(actualImageProvider)
|
||||
}
|
||||
}, [addProvider, imageProviderId, imageProviders, setImageProviderId])
|
||||
|
||||
return {
|
||||
providers,
|
||||
imageProvider,
|
||||
addProvider,
|
||||
removeProvider,
|
||||
setImageProviderId,
|
||||
getOcrProviderName,
|
||||
OcrProviderLogo
|
||||
}
|
||||
}
|
||||
|
||||
export const useOcrProvider = (id: string) => {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useDispatch()
|
||||
const { providers, addProvider } = useOcrProviders()
|
||||
let provider = providers.find((p) => p.id === id)
|
||||
|
||||
// safely fallback
|
||||
if (!provider) {
|
||||
logger.error(`Ocr Provider ${id} not found`)
|
||||
window.message.error(t('ocr.error.provider.not_found'))
|
||||
if (isBuiltinOcrProviderId(id)) {
|
||||
try {
|
||||
addProvider(BUILTIN_OCR_PROVIDERS_MAP[id])
|
||||
} catch (e) {
|
||||
logger.warn(`Add ${BUILTIN_OCR_PROVIDERS_MAP[id].name} failed. Just use temp provider from config.`)
|
||||
window.message.warning(t('ocr.warning.provider.fallback', { name: BUILTIN_OCR_PROVIDERS_MAP[id].name }))
|
||||
} finally {
|
||||
provider = BUILTIN_OCR_PROVIDERS_MAP[id]
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Fallback to tesseract`)
|
||||
window.message.warning(t('ocr.warning.provider.fallback', { name: 'Tesseract' }))
|
||||
provider = BUILTIN_OCR_PROVIDERS_MAP.tesseract
|
||||
}
|
||||
}
|
||||
|
||||
const updateConfig = (update: Partial<OcrProviderConfig>) => {
|
||||
dispatch(updateOcrProviderConfig({ id: provider.id, update }))
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
updateConfig
|
||||
}
|
||||
}
|
||||
@ -5,8 +5,7 @@
|
||||
*/
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@renderer/types'
|
||||
import { ThinkingOption } from '@renderer/types'
|
||||
import { BuiltinMCPServerName, BuiltinMCPServerNames, BuiltinOcrProviderId, ThinkingOption } from '@renderer/types'
|
||||
|
||||
import i18n from './index'
|
||||
|
||||
@ -96,17 +95,32 @@ export const getProviderLabel = (id: string): string => {
|
||||
return getLabel(id, providerKeyMap)
|
||||
}
|
||||
|
||||
const progressKeyMap = {
|
||||
const backupProgressKeyMap = {
|
||||
completed: 'backup.progress.completed',
|
||||
compressing: 'backup.progress.compressing',
|
||||
copying_files: 'backup.progress.copying_files',
|
||||
preparing_compression: 'backup.progress.preparing_compression',
|
||||
preparing: 'backup.progress.preparing',
|
||||
title: 'backup.progress.title',
|
||||
writing_data: 'backup.progress.writing_data'
|
||||
} as const
|
||||
|
||||
export const getProgressLabel = (key: string): string => {
|
||||
return getLabel(key, progressKeyMap)
|
||||
export const getBackupProgressLabel = (key: string): string => {
|
||||
return getLabel(key, backupProgressKeyMap)
|
||||
}
|
||||
|
||||
const restoreProgressKeyMap = {
|
||||
completed: 'restore.progress.completed',
|
||||
copying_files: 'restore.progress.copying_files',
|
||||
extracted: 'restore.progress.extracted',
|
||||
extracting: 'restore.progress.extracting',
|
||||
preparing: 'restore.progress.preparing',
|
||||
reading_data: 'restore.progress.reading_data',
|
||||
title: 'restore.progress.title'
|
||||
}
|
||||
|
||||
export const getRestoreProgressLabel = (key: string): string => {
|
||||
return getLabel(key, restoreProgressKeyMap)
|
||||
}
|
||||
|
||||
const titleKeyMap = {
|
||||
@ -307,3 +321,13 @@ const builtInMcpDescriptionKeyMap: Record<BuiltinMCPServerName, string> = {
|
||||
export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
|
||||
return getLabel(key, builtInMcpDescriptionKeyMap, t('settings.mcp.builtinServersDescriptions.no'))
|
||||
}
|
||||
|
||||
const builtinOcrProviderKeyMap = {
|
||||
system: 'ocr.builtin.system',
|
||||
tesseract: ''
|
||||
} as const satisfies Record<BuiltinOcrProviderId, string>
|
||||
|
||||
export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => {
|
||||
if (key === 'tesseract') return 'Tesseract'
|
||||
else return getLabel(key, builtinOcrProviderKeyMap)
|
||||
}
|
||||
|
||||
@ -240,6 +240,7 @@
|
||||
"compressing": "Compressing files...",
|
||||
"copying_files": "Copying files... {{progress}}%",
|
||||
"preparing": "Preparing backup...",
|
||||
"preparing_compression": "Preparing compression...",
|
||||
"title": "Backup Progress",
|
||||
"writing_data": "Writing data..."
|
||||
},
|
||||
@ -875,6 +876,8 @@
|
||||
"files": {
|
||||
"actions": "Actions",
|
||||
"all": "All Files",
|
||||
"batch_delete": "Batch delete",
|
||||
"batch_operation": "Select All",
|
||||
"count": "files",
|
||||
"created_at": "Created At",
|
||||
"delete": {
|
||||
@ -923,6 +926,11 @@
|
||||
"title": "Topics Search"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "Capture Page",
|
||||
"to_clipboard": "Copy to Clipboard",
|
||||
"to_file": "Save as Image"
|
||||
},
|
||||
"code": "Code",
|
||||
"empty_preview": "No content to display",
|
||||
"generating": "Generating",
|
||||
@ -1594,6 +1602,9 @@
|
||||
"tip": "If the response is successful, then only messages exceeding 30 seconds will trigger a reminder"
|
||||
},
|
||||
"ocr": {
|
||||
"builtin": {
|
||||
"system": "System OCR"
|
||||
},
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "Cannot delete built-in provider",
|
||||
@ -3538,17 +3549,30 @@
|
||||
"title": "Settings",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"common": {
|
||||
"langs": "Supported languages"
|
||||
},
|
||||
"error": {
|
||||
"not_system": "System OCR only supports Windows and MacOS"
|
||||
},
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "The provider does not exist"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "Supported languages",
|
||||
"temp_tooltip": "Currently only Chinese and English are supported"
|
||||
"system": {
|
||||
"no_need_configure": "MacOS requires no configuration"
|
||||
},
|
||||
"title": "Image"
|
||||
},
|
||||
"image_provider": "OCR service provider",
|
||||
"system": {
|
||||
"win": {
|
||||
"langs_tooltip": "Dependent on Windows to provide services, you need to download language packs in the system to support the relevant languages."
|
||||
}
|
||||
},
|
||||
"tesseract": {
|
||||
"langs_tooltip": "Read the documentation to learn which custom languages are supported"
|
||||
},
|
||||
"title": "OCR service"
|
||||
},
|
||||
"preprocess": {
|
||||
@ -3776,6 +3800,7 @@
|
||||
},
|
||||
"empty": "Translation content is empty",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Qwen MT model cannot be used in chat. Please go to the translation page.",
|
||||
"detect": {
|
||||
"qwen_mt": "QwenMT model cannot be used for language detection",
|
||||
"unknown": "Unknown language detected",
|
||||
@ -3794,6 +3819,7 @@
|
||||
"files": {
|
||||
"drag_text": "Drop here",
|
||||
"error": {
|
||||
"check_type": "An error occurred while checking the file type",
|
||||
"multiple": "Multiple file uploads are not allowed",
|
||||
"too_large": "File too large",
|
||||
"unknown": "Failed to read file content"
|
||||
@ -3818,7 +3844,7 @@
|
||||
"aborted": "Translation aborted"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Enter text to translate"
|
||||
"placeholder": "Text, files, or images (OCR supported) can be pasted or dragged in"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "Source language is different from the set language",
|
||||
|
||||
@ -240,6 +240,7 @@
|
||||
"compressing": "圧縮中...",
|
||||
"copying_files": "ファイルコピー中... {{progress}}%",
|
||||
"preparing": "バックアップ準備中...",
|
||||
"preparing_compression": "圧縮準備中...",
|
||||
"title": "バックアップ進捗",
|
||||
"writing_data": "データ書き込み中..."
|
||||
},
|
||||
@ -874,6 +875,8 @@
|
||||
"files": {
|
||||
"actions": "操作",
|
||||
"all": "すべてのファイル",
|
||||
"batch_delete": "一括削除",
|
||||
"batch_operation": "すべて選択",
|
||||
"count": "ファイル",
|
||||
"created_at": "作成日",
|
||||
"delete": {
|
||||
@ -922,6 +925,11 @@
|
||||
"title": "トピック検索"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "ページをキャプチャ",
|
||||
"to_clipboard": "クリップボードにコピー",
|
||||
"to_file": "画像として保存"
|
||||
},
|
||||
"code": "コード",
|
||||
"empty_preview": "表示するコンテンツがありません",
|
||||
"generating": "生成中",
|
||||
@ -1593,6 +1601,9 @@
|
||||
"tip": "応答が成功した場合、30秒を超えるメッセージのみに通知を行います"
|
||||
},
|
||||
"ocr": {
|
||||
"builtin": {
|
||||
"system": "システム OCR"
|
||||
},
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "組み込みプロバイダーは削除できません",
|
||||
@ -3537,17 +3548,30 @@
|
||||
"title": "設定",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"common": {
|
||||
"langs": "サポートされている言語"
|
||||
},
|
||||
"error": {
|
||||
"not_system": "システムOCRはWindowsとMacOSのみをサポートしています"
|
||||
},
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "該提供者は存在しません"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "サポートされている言語",
|
||||
"temp_tooltip": "現在のところ、中国語と英語のみをサポートしています"
|
||||
"system": {
|
||||
"no_need_configure": "MacOS は設定不要"
|
||||
},
|
||||
"title": "画像"
|
||||
},
|
||||
"image_provider": "OCRサービスプロバイダー",
|
||||
"system": {
|
||||
"win": {
|
||||
"langs_tooltip": "Windows が提供するサービスに依存しており、関連する言語をサポートするには、システムで言語パックをダウンロードする必要があります。"
|
||||
}
|
||||
},
|
||||
"tesseract": {
|
||||
"langs_tooltip": "ドキュメントを読んで、どのカスタム言語がサポートされているかを確認してください。"
|
||||
},
|
||||
"title": "OCRサービス"
|
||||
},
|
||||
"preprocess": {
|
||||
@ -3775,6 +3799,7 @@
|
||||
},
|
||||
"empty": "翻訳内容が空です",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Qwen MT モデルは対話で使用できません。翻訳ページに移動してください",
|
||||
"detect": {
|
||||
"qwen_mt": "QwenMTモデルは言語検出に使用できません",
|
||||
"unknown": "検出された言語は不明です",
|
||||
@ -3793,6 +3818,7 @@
|
||||
"files": {
|
||||
"drag_text": "ここにドラッグ&ドロップしてください",
|
||||
"error": {
|
||||
"check_type": "ファイルタイプの確認中にエラーが発生しました",
|
||||
"multiple": "複数のファイルのアップロードは許可されていません",
|
||||
"too_large": "ファイルが大きすぎます",
|
||||
"unknown": "ファイルの内容を読み取るのに失敗しました"
|
||||
@ -3817,7 +3843,7 @@
|
||||
"aborted": "翻訳中止"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "翻訳するテキストを入力"
|
||||
"placeholder": "テキスト、ファイル、画像(OCR対応)を貼り付けたりドラッグアンドドロップしたりできます"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "ソース言語が設定された言語と異なります",
|
||||
|
||||
@ -240,6 +240,7 @@
|
||||
"compressing": "Сжатие файлов...",
|
||||
"copying_files": "Копирование файлов... {{progress}}%",
|
||||
"preparing": "Подготовка резервной копии...",
|
||||
"preparing_compression": "Подготовка сжатия...",
|
||||
"title": "Прогресс резервного копирования",
|
||||
"writing_data": "Запись данных..."
|
||||
},
|
||||
@ -874,6 +875,8 @@
|
||||
"files": {
|
||||
"actions": "Действия",
|
||||
"all": "Все файлы",
|
||||
"batch_delete": "массовое удаление",
|
||||
"batch_operation": "Выделить всё",
|
||||
"count": "файлов",
|
||||
"created_at": "Дата создания",
|
||||
"delete": {
|
||||
@ -922,6 +925,11 @@
|
||||
"title": "Поиск топиков"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "Захват страницы",
|
||||
"to_clipboard": "Копировать в буфер обмена",
|
||||
"to_file": "Сохранить как изображение"
|
||||
},
|
||||
"code": "Код",
|
||||
"empty_preview": "Нет содержания для отображения",
|
||||
"generating": "Генерация",
|
||||
@ -1593,6 +1601,9 @@
|
||||
"tip": "Если ответ успешен, уведомление выдается только по сообщениям, превышающим 30 секунд"
|
||||
},
|
||||
"ocr": {
|
||||
"builtin": {
|
||||
"system": "Системное распознавание текста"
|
||||
},
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "Не удается удалить встроенного поставщика",
|
||||
@ -3537,17 +3548,30 @@
|
||||
"title": "Настройки",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"common": {
|
||||
"langs": "Поддерживаемые языки"
|
||||
},
|
||||
"error": {
|
||||
"not_system": "Системный OCR поддерживается только в Windows и MacOS"
|
||||
},
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "Поставщик не существует"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "Поддерживаемые языки",
|
||||
"temp_tooltip": "На данный момент поддерживаются только китайский и английский языки"
|
||||
"system": {
|
||||
"no_need_configure": "MacOS не требует настройки"
|
||||
},
|
||||
"title": "Изображение"
|
||||
},
|
||||
"image_provider": "Поставщик услуг OCR",
|
||||
"system": {
|
||||
"win": {
|
||||
"langs_tooltip": "Для предоставления служб Windows необходимо загрузить языковой пакет в системе для поддержки соответствующего языка."
|
||||
}
|
||||
},
|
||||
"tesseract": {
|
||||
"langs_tooltip": "Ознакомьтесь с документацией, чтобы узнать, какие пользовательские языки поддерживаются"
|
||||
},
|
||||
"title": "OCR-сервис"
|
||||
},
|
||||
"preprocess": {
|
||||
@ -3775,6 +3799,7 @@
|
||||
},
|
||||
"empty": "Содержимое перевода пусто",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Модель Qwen MT недоступна для использования в диалоге, перейдите на страницу перевода",
|
||||
"detect": {
|
||||
"qwen_mt": "Модель QwenMT не может использоваться для определения языка",
|
||||
"unknown": "Обнаружен неизвестный язык",
|
||||
@ -3793,6 +3818,7 @@
|
||||
"files": {
|
||||
"drag_text": "Перетащите сюда",
|
||||
"error": {
|
||||
"check_type": "Ошибка при проверке типа файла",
|
||||
"multiple": "Не разрешается загружать несколько файлов",
|
||||
"too_large": "Файл слишком большой",
|
||||
"unknown": "Ошибка при чтении содержимого файла"
|
||||
@ -3817,7 +3843,7 @@
|
||||
"aborted": "Перевод прерван"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Введите текст для перевода"
|
||||
"placeholder": "Можно вставить или перетащить текст, файлы, изображения (поддержка OCR)"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "Исходный язык отличается от настроенного",
|
||||
|
||||
@ -240,6 +240,7 @@
|
||||
"compressing": "压缩文件...",
|
||||
"copying_files": "复制文件... {{progress}}%",
|
||||
"preparing": "准备备份...",
|
||||
"preparing_compression": "准备压缩...",
|
||||
"title": "备份进度",
|
||||
"writing_data": "写入数据..."
|
||||
},
|
||||
@ -875,6 +876,8 @@
|
||||
"files": {
|
||||
"actions": "操作",
|
||||
"all": "所有文件",
|
||||
"batch_delete": "批量删除",
|
||||
"batch_operation": "全选",
|
||||
"count": "个文件",
|
||||
"created_at": "创建时间",
|
||||
"delete": {
|
||||
@ -923,6 +926,11 @@
|
||||
"title": "话题搜索"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "捕获页面",
|
||||
"to_clipboard": "复制到剪贴板",
|
||||
"to_file": "保存为图片"
|
||||
},
|
||||
"code": "代码",
|
||||
"empty_preview": "无内容可展示",
|
||||
"generating": "生成中",
|
||||
@ -1594,6 +1602,9 @@
|
||||
"tip": "如果响应成功,则只针对超过30秒的消息进行提醒"
|
||||
},
|
||||
"ocr": {
|
||||
"builtin": {
|
||||
"system": "系统 OCR"
|
||||
},
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "不能删除内置提供商",
|
||||
@ -3193,11 +3204,11 @@
|
||||
"provider_key_add_failed_by_empty_data": "添加服务商 API 密钥失败,数据为空",
|
||||
"provider_key_add_failed_by_invalid_data": "添加服务商 API 密钥失败,数据格式错误",
|
||||
"provider_key_added": "成功为 {{provider}} 添加 API 密钥",
|
||||
"provider_key_already_exists": "{{provider}} 已存在相同API 密钥, 不会重复添加",
|
||||
"provider_key_already_exists": "{{provider}} 已存在相同API 密钥,不会重复添加",
|
||||
"provider_key_confirm_title": "为{{provider}}添加 API 密钥",
|
||||
"provider_key_no_change": "{{provider}} 的 API 密钥没有变化",
|
||||
"provider_key_overridden": "成功更新 {{provider}} 的 API 密钥",
|
||||
"provider_key_override_confirm": "{{provider}} 已存在相同 API 密钥, 是否覆盖?",
|
||||
"provider_key_override_confirm": "{{provider}} 已存在相同 API 密钥,是否覆盖?",
|
||||
"provider_name": "服务商名称",
|
||||
"quick_assistant_default_tag": "默认",
|
||||
"quick_assistant_model": "快捷助手模型",
|
||||
@ -3538,17 +3549,30 @@
|
||||
"title": "设置",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"common": {
|
||||
"langs": "支持的语言"
|
||||
},
|
||||
"error": {
|
||||
"not_system": "系统 OCR 仅支持 Windows 与 MacOS"
|
||||
},
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "该提供商不存在"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "支持的语言",
|
||||
"temp_tooltip": "目前暂时只支持中文和英文"
|
||||
"system": {
|
||||
"no_need_configure": "MacOS 无需配置"
|
||||
},
|
||||
"title": "图片"
|
||||
},
|
||||
"image_provider": "OCR 服务提供商",
|
||||
"system": {
|
||||
"win": {
|
||||
"langs_tooltip": "依赖 Windows 提供服务,您需要在系统中下载语言包来支持相关语言。"
|
||||
}
|
||||
},
|
||||
"tesseract": {
|
||||
"langs_tooltip": "阅读文档以了解哪些自定义语言是受支持的"
|
||||
},
|
||||
"title": "OCR 服务"
|
||||
},
|
||||
"preprocess": {
|
||||
@ -3776,6 +3800,7 @@
|
||||
},
|
||||
"empty": "翻译内容为空",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Qwen MT 模型不可在对话中使用,请转至翻译页面",
|
||||
"detect": {
|
||||
"qwen_mt": "QwenMT模型不能用于语言检测",
|
||||
"unknown": "检测到未知语言",
|
||||
@ -3794,6 +3819,7 @@
|
||||
"files": {
|
||||
"drag_text": "拖放到此处",
|
||||
"error": {
|
||||
"check_type": "检查文件类型时发生错误",
|
||||
"multiple": "不允许上传多个文件",
|
||||
"too_large": "文件过大",
|
||||
"unknown": "读取文件内容失败"
|
||||
@ -3818,7 +3844,7 @@
|
||||
"aborted": "翻译中止"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "输入文本进行翻译"
|
||||
"placeholder": "可粘贴或拖入文本、文件、图片(支持OCR)"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "源语言与设置的语言不同",
|
||||
|
||||
@ -240,6 +240,7 @@
|
||||
"compressing": "壓縮檔案...",
|
||||
"copying_files": "複製檔案... {{progress}}%",
|
||||
"preparing": "準備備份...",
|
||||
"preparing_compression": "準備壓縮...",
|
||||
"title": "備份進度",
|
||||
"writing_data": "寫入資料..."
|
||||
},
|
||||
@ -874,6 +875,8 @@
|
||||
"files": {
|
||||
"actions": "操作",
|
||||
"all": "所有檔案",
|
||||
"batch_delete": "批次刪除",
|
||||
"batch_operation": "全選",
|
||||
"count": "個檔案",
|
||||
"created_at": "建立時間",
|
||||
"delete": {
|
||||
@ -922,6 +925,11 @@
|
||||
"title": "搜尋話題"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "捕獲頁面",
|
||||
"to_clipboard": "複製到剪貼簿",
|
||||
"to_file": "保存為圖片"
|
||||
},
|
||||
"code": "程式碼",
|
||||
"empty_preview": "無內容可展示",
|
||||
"generating": "生成中",
|
||||
@ -1593,6 +1601,9 @@
|
||||
"tip": "如果回應成功,則只針對超過30秒的訊息發出提醒"
|
||||
},
|
||||
"ocr": {
|
||||
"builtin": {
|
||||
"system": "系统 OCR"
|
||||
},
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "不能刪除內建提供者",
|
||||
@ -3192,11 +3203,11 @@
|
||||
"provider_key_add_failed_by_empty_data": "添加提供者 API 密鑰失敗,數據為空",
|
||||
"provider_key_add_failed_by_invalid_data": "添加提供者 API 密鑰失敗,數據格式錯誤",
|
||||
"provider_key_added": "成功為 {{provider}} 添加 API 密鑰",
|
||||
"provider_key_already_exists": "{{provider}} 已存在相同API 密鑰, 不會重複添加",
|
||||
"provider_key_already_exists": "{{provider}} 已存在相同API 密鑰,不會重複添加",
|
||||
"provider_key_confirm_title": "為{{provider}}添加 API 密鑰",
|
||||
"provider_key_no_change": "{{provider}} 的 API 密鑰沒有變化",
|
||||
"provider_key_overridden": "成功更新 {{provider}} 的 API 密鑰",
|
||||
"provider_key_override_confirm": "{{provider}} 已存在相同 API 金鑰, 是否覆蓋?",
|
||||
"provider_key_override_confirm": "{{provider}} 已存在相同 API 金鑰,是否覆蓋?",
|
||||
"provider_name": "提供者名稱",
|
||||
"quick_assistant_default_tag": "預設",
|
||||
"quick_assistant_model": "快捷助手模型",
|
||||
@ -3537,17 +3548,30 @@
|
||||
"title": "設定",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"common": {
|
||||
"langs": "支援的語言"
|
||||
},
|
||||
"error": {
|
||||
"not_system": "系統 OCR 僅支援 Windows 與 MacOS"
|
||||
},
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "該提供商不存在"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "支援的語言",
|
||||
"temp_tooltip": "目前暫時只支援中文和英文"
|
||||
"system": {
|
||||
"no_need_configure": "MacOS 無需配置"
|
||||
},
|
||||
"title": "圖片"
|
||||
},
|
||||
"image_provider": "OCR 服務提供商",
|
||||
"system": {
|
||||
"win": {
|
||||
"langs_tooltip": "依賴 Windows 提供服務,您需要在系統中下載語言包來支援相關語言。"
|
||||
}
|
||||
},
|
||||
"tesseract": {
|
||||
"langs_tooltip": "閱讀文件以了解哪些自訂語言受支援"
|
||||
},
|
||||
"title": "OCR 服務"
|
||||
},
|
||||
"preprocess": {
|
||||
@ -3775,6 +3799,7 @@
|
||||
},
|
||||
"empty": "翻譯內容為空",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Qwen MT 模型不可在对话中使用,請轉至翻譯頁面",
|
||||
"detect": {
|
||||
"qwen_mt": "QwenMT模型不能用於語言檢測",
|
||||
"unknown": "檢測到未知語言",
|
||||
@ -3793,6 +3818,7 @@
|
||||
"files": {
|
||||
"drag_text": "拖放到此处",
|
||||
"error": {
|
||||
"check_type": "檢查檔案類型時發生錯誤",
|
||||
"multiple": "不允许上传多个文件",
|
||||
"too_large": "文件過大",
|
||||
"unknown": "读取文件内容失败"
|
||||
@ -3817,7 +3843,7 @@
|
||||
"aborted": "翻譯中止"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "輸入文字進行翻譯"
|
||||
"placeholder": "可粘貼或拖入文字、檔案、圖片(支援OCR)"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "源語言與設定的語言不同",
|
||||
|
||||
@ -240,6 +240,7 @@
|
||||
"compressing": "Συμπίεση αρχείων...",
|
||||
"copying_files": "Αντιγραφή αρχείων... {{progress}}%",
|
||||
"preparing": "Ετοιμασία αντιγράφου ασφαλείας...",
|
||||
"preparing_compression": "Ετοιμασία συμπίεσης...",
|
||||
"title": "Πρόοδος αντιγράφου ασφαλείας",
|
||||
"writing_data": "Εγγραφή δεδομένων..."
|
||||
},
|
||||
@ -867,6 +868,8 @@
|
||||
"files": {
|
||||
"actions": "Ενέργειες",
|
||||
"all": "Όλα τα αρχεία",
|
||||
"batch_delete": "μαζική διαγραφή",
|
||||
"batch_operation": "Επιλογή όλων",
|
||||
"count": "Αριθμός αρχείων",
|
||||
"created_at": "Ημερομηνία δημιουργίας",
|
||||
"delete": {
|
||||
@ -915,6 +918,11 @@
|
||||
"title": "Αναζήτηση θεμάτων"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "Καταγραφή σελίδας",
|
||||
"to_clipboard": "Αντιγραφή στο πρόχειρο",
|
||||
"to_file": "Αποθήκευση ως εικόνα"
|
||||
},
|
||||
"code": "Κώδικας",
|
||||
"empty_preview": "Δεν υπάρχει περιεχόμενο για εμφάνιση",
|
||||
"generating": "Δημιουργία",
|
||||
@ -1586,6 +1594,9 @@
|
||||
"tip": "Εάν η απάντηση είναι επιτυχής, η ειδοποίηση εμφανίζεται μόνο για μηνύματα που υπερβαίνουν τα 30 δευτερόλεπτα"
|
||||
},
|
||||
"ocr": {
|
||||
"builtin": {
|
||||
"system": "σύστημα OCR"
|
||||
},
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου",
|
||||
@ -2726,7 +2737,7 @@
|
||||
"title": "Αυτόματη ενημέρωση"
|
||||
},
|
||||
"avatar": {
|
||||
"builtin": "Ενσωματωμένο αναγνωριστικό προφίλ",
|
||||
"builtin": "Ενσωματωμένο προφίλ",
|
||||
"reset": "Επαναφορά εικονιδίου"
|
||||
},
|
||||
"backup": {
|
||||
@ -3530,17 +3541,30 @@
|
||||
"title": "Ρυθμίσεις",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"common": {
|
||||
"langs": "Υποστηριζόμενες γλώσσες"
|
||||
},
|
||||
"error": {
|
||||
"not_system": "Το σύστημα OCR υποστηρίζει μόνο Windows και MacOS"
|
||||
},
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "Ο πάροχος δεν υπάρχει"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "Υποστηριζόμενες γλώσσες",
|
||||
"temp_tooltip": "Προς το παρόν υποστηρίζονται μόνο η κινεζική και η αγγλική γλώσσα"
|
||||
"system": {
|
||||
"no_need_configure": "MacOS δεν απαιτεί ρύθμιση"
|
||||
},
|
||||
"title": "Εικόνα"
|
||||
},
|
||||
"image_provider": "Πάροχοι υπηρεσιών OCR",
|
||||
"system": {
|
||||
"win": {
|
||||
"langs_tooltip": "Εξαρτάται από τα Windows για την παροχή υπηρεσιών, πρέπει να κατεβάσετε το πακέτο γλώσσας στο σύστημα για να υποστηρίξετε τις σχετικές γλώσσες."
|
||||
}
|
||||
},
|
||||
"tesseract": {
|
||||
"langs_tooltip": "Διαβάστε την τεκμηρίωση για να μάθετε ποιες προσαρμοσμένες γλώσσες υποστηρίζονται"
|
||||
},
|
||||
"title": "Υπηρεσία OCR"
|
||||
},
|
||||
"preprocess": {
|
||||
@ -3768,6 +3792,7 @@
|
||||
},
|
||||
"empty": "Το μεταφρασμένο κείμενο είναι κενό",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Τα μοντέλα Qwen MT δεν είναι διαθέσιμα για χρήση σε διαλόγους, παρακαλώ μεταβείτε στη σελίδα μετάφρασης",
|
||||
"detect": {
|
||||
"qwen_mt": "Το μοντέλο QwenMT δεν μπορεί να χρησιμοποιηθεί για εντοπισμό γλώσσας",
|
||||
"unknown": "Ανιχνεύθηκε άγνωστη γλώσσα",
|
||||
@ -3786,6 +3811,7 @@
|
||||
"files": {
|
||||
"drag_text": "Σύρετε και αφήστε εδώ",
|
||||
"error": {
|
||||
"check_type": "Παρουσιάστηκε σφάλμα κατά τον έλεγχο του τύπου αρχείου",
|
||||
"multiple": "Δεν επιτρέπεται η μεταφόρτωση πολλαπλών αρχείων",
|
||||
"too_large": "Το αρχείο είναι πολύ μεγάλο",
|
||||
"unknown": "Αποτυχία ανάγνωσης του περιεχομένου του αρχείου"
|
||||
@ -3810,7 +3836,7 @@
|
||||
"aborted": "Η μετάφραση διακόπηκε"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Εισαγάγετε κείμενο για μετάφραση"
|
||||
"placeholder": "Μπορείτε να επικολλήσετε ή να σύρετε κείμενο, αρχεία, εικόνες (με υποστήριξη OCR)"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "Η γλώσσα πηγής διαφέρει από την οριζόμενη γλώσσα",
|
||||
|
||||
@ -240,6 +240,7 @@
|
||||
"compressing": "Comprimiendo archivos...",
|
||||
"copying_files": "Copiando archivos... {{progress}}%",
|
||||
"preparing": "Preparando copia de seguridad...",
|
||||
"preparing_compression": "Preparando compresión...",
|
||||
"title": "Progreso de la copia de seguridad",
|
||||
"writing_data": "Escribiendo datos..."
|
||||
},
|
||||
@ -867,6 +868,8 @@
|
||||
"files": {
|
||||
"actions": "Acciones",
|
||||
"all": "Todos los archivos",
|
||||
"batch_delete": "Eliminación masiva",
|
||||
"batch_operation": "Seleccionar todo",
|
||||
"count": "Número de archivos",
|
||||
"created_at": "Fecha de creación",
|
||||
"delete": {
|
||||
@ -915,6 +918,11 @@
|
||||
"title": "Búsqueda de temas"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "Capturar página",
|
||||
"to_clipboard": "Copiar al portapapeles",
|
||||
"to_file": "Guardar como imagen"
|
||||
},
|
||||
"code": "Código",
|
||||
"empty_preview": "Sin contenido para mostrar",
|
||||
"generating": "Generando",
|
||||
@ -1586,6 +1594,9 @@
|
||||
"tip": "Si la respuesta es exitosa, solo se enviará un recordatorio para mensajes que excedan los 30 segundos"
|
||||
},
|
||||
"ocr": {
|
||||
"builtin": {
|
||||
"system": "OCR del sistema"
|
||||
},
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "No se puede eliminar el proveedor integrado",
|
||||
@ -3530,17 +3541,30 @@
|
||||
"title": "Configuración",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"common": {
|
||||
"langs": "Idiomas compatibles"
|
||||
},
|
||||
"error": {
|
||||
"not_system": "El OCR del sistema solo admite Windows y MacOS"
|
||||
},
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "El proveedor no existe"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "Idiomas compatibles",
|
||||
"temp_tooltip": "Actualmente solo se admiten chino e inglés."
|
||||
"system": {
|
||||
"no_need_configure": "MacOS no requiere configuración"
|
||||
},
|
||||
"title": "Imagen"
|
||||
},
|
||||
"image_provider": "Proveedor de servicios OCR",
|
||||
"system": {
|
||||
"win": {
|
||||
"langs_tooltip": "Dependiendo de Windows para proporcionar servicios, necesita descargar el paquete de idioma en el sistema para admitir los idiomas correspondientes."
|
||||
}
|
||||
},
|
||||
"tesseract": {
|
||||
"langs_tooltip": "Lea la documentación para conocer qué idiomas personalizados son compatibles"
|
||||
},
|
||||
"title": "Servicio OCR"
|
||||
},
|
||||
"preprocess": {
|
||||
@ -3768,6 +3792,7 @@
|
||||
},
|
||||
"empty": "El contenido de traducción está vacío",
|
||||
"error": {
|
||||
"chat_qwen_mt": "El modelo Qwen MT no está disponible para uso en conversaciones, por favor vaya a la página de traducción.",
|
||||
"detect": {
|
||||
"qwen_mt": "El modelo QwenMT no se puede utilizar para la detección de idiomas",
|
||||
"unknown": "Se detectó un idioma desconocido",
|
||||
@ -3786,6 +3811,7 @@
|
||||
"files": {
|
||||
"drag_text": "Arrastrar y soltar aquí",
|
||||
"error": {
|
||||
"check_type": "Se produjo un error al verificar el tipo de archivo",
|
||||
"multiple": "No se permite cargar varios archivos",
|
||||
"too_large": "El archivo es demasiado grande",
|
||||
"unknown": "Error al leer el contenido del archivo"
|
||||
@ -3810,7 +3836,7 @@
|
||||
"aborted": "Traducción cancelada"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Ingrese el texto para traducir"
|
||||
"placeholder": "Se puede pegar o arrastrar texto, archivos e imágenes (compatible con OCR)"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "El idioma de origen es diferente al idioma configurado",
|
||||
|
||||
@ -240,6 +240,7 @@
|
||||
"compressing": "Compression des fichiers...",
|
||||
"copying_files": "Copie des fichiers... {{progress}}%",
|
||||
"preparing": "Préparation de la sauvegarde...",
|
||||
"preparing_compression": "Préparation de la compression...",
|
||||
"title": "Progrès de la sauvegarde",
|
||||
"writing_data": "Écriture des données..."
|
||||
},
|
||||
@ -867,6 +868,8 @@
|
||||
"files": {
|
||||
"actions": "Actions",
|
||||
"all": "Tous les fichiers",
|
||||
"batch_delete": "supprimer en masse",
|
||||
"batch_operation": "Tout sélectionner",
|
||||
"count": "Nombre de fichiers",
|
||||
"created_at": "Date de création",
|
||||
"delete": {
|
||||
@ -915,6 +918,11 @@
|
||||
"title": "Recherche de sujets"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "Capturer la page",
|
||||
"to_clipboard": "Copier dans le presse-papiers",
|
||||
"to_file": "Enregistrer en tant qu'image"
|
||||
},
|
||||
"code": "Code",
|
||||
"empty_preview": "Aucun contenu à afficher",
|
||||
"generating": "Génération",
|
||||
@ -1586,6 +1594,9 @@
|
||||
"tip": "Si la réponse est réussie, un rappel est envoyé uniquement pour les messages dépassant 30 secondes"
|
||||
},
|
||||
"ocr": {
|
||||
"builtin": {
|
||||
"system": "OCR système"
|
||||
},
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré",
|
||||
@ -3530,17 +3541,30 @@
|
||||
"title": "Paramètres",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"common": {
|
||||
"langs": "Langues prises en charge"
|
||||
},
|
||||
"error": {
|
||||
"not_system": "L'OCR système prend uniquement en charge Windows et MacOS"
|
||||
},
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "Ce fournisseur n'existe pas"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "Langues prises en charge",
|
||||
"temp_tooltip": "Pour le moment, seuls le chinois et l'anglais sont pris en charge."
|
||||
"system": {
|
||||
"no_need_configure": "MacOS ne nécessite aucune configuration"
|
||||
},
|
||||
"title": "Image"
|
||||
},
|
||||
"image_provider": "Fournisseur de service OCR",
|
||||
"system": {
|
||||
"win": {
|
||||
"langs_tooltip": "Dépendre de Windows pour fournir des services, vous devez télécharger des packs linguistiques dans le système afin de prendre en charge les langues concernées."
|
||||
}
|
||||
},
|
||||
"tesseract": {
|
||||
"langs_tooltip": "Lisez la documentation pour connaître les langues personnalisées prises en charge"
|
||||
},
|
||||
"title": "Service OCR"
|
||||
},
|
||||
"preprocess": {
|
||||
@ -3768,6 +3792,7 @@
|
||||
},
|
||||
"empty": "Le contenu à traduire est vide",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Les modèles Qwen MT ne peuvent pas être utilisés dans les conversations, veuillez vous rendre sur la page de traduction.",
|
||||
"detect": {
|
||||
"qwen_mt": "Le modèle QwenMT ne peut pas être utilisé pour la détection de langues",
|
||||
"unknown": "Langue inconnue détectée",
|
||||
@ -3786,6 +3811,7 @@
|
||||
"files": {
|
||||
"drag_text": "Glisser-déposer ici",
|
||||
"error": {
|
||||
"check_type": "Une erreur s'est produite lors de la vérification du type de fichier",
|
||||
"multiple": "Impossible de téléverser plusieurs fichiers",
|
||||
"too_large": "Fichier trop volumineux",
|
||||
"unknown": "Échec de la lecture du contenu du fichier"
|
||||
@ -3810,7 +3836,7 @@
|
||||
"aborted": "Traduction annulée"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "entrez le texte à traduire"
|
||||
"placeholder": "Peut coller ou glisser du texte, des fichiers, des images (avec reconnaissance optique de caractères)"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "La langue source est différente de la langue définie",
|
||||
|
||||
@ -240,6 +240,7 @@
|
||||
"compressing": "Comprimindo arquivo...",
|
||||
"copying_files": "Copiando arquivos... {{progress}}%",
|
||||
"preparing": "Preparando backup...",
|
||||
"preparing_compression": "Preparando compressão...",
|
||||
"title": "Progresso do Backup",
|
||||
"writing_data": "Escrevendo dados..."
|
||||
},
|
||||
@ -867,6 +868,8 @@
|
||||
"files": {
|
||||
"actions": "Ações",
|
||||
"all": "Todos os Arquivos",
|
||||
"batch_delete": "excluir em massa",
|
||||
"batch_operation": "Selecionar tudo",
|
||||
"count": "Número de Arquivos",
|
||||
"created_at": "Data de Criação",
|
||||
"delete": {
|
||||
@ -915,6 +918,11 @@
|
||||
"title": "Procurar Tópicos"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "Capturar página",
|
||||
"to_clipboard": "Copiar para a área de transferência",
|
||||
"to_file": "Salvar como imagem"
|
||||
},
|
||||
"code": "Código",
|
||||
"empty_preview": "Sem conteúdo para exibir",
|
||||
"generating": "Gerando",
|
||||
@ -1586,6 +1594,9 @@
|
||||
"tip": "Se a resposta for bem-sucedida, lembrete apenas para mensagens que excedam 30 segundos"
|
||||
},
|
||||
"ocr": {
|
||||
"builtin": {
|
||||
"system": "OCR do sistema"
|
||||
},
|
||||
"error": {
|
||||
"provider": {
|
||||
"cannot_remove_builtin": "Não é possível excluir o provedor integrado",
|
||||
@ -2726,7 +2737,7 @@
|
||||
"title": "Atualização automática"
|
||||
},
|
||||
"avatar": {
|
||||
"builtin": "Avatares integrados",
|
||||
"builtin": "Avatares embutidos",
|
||||
"reset": "Redefinir avatar"
|
||||
},
|
||||
"backup": {
|
||||
@ -3530,17 +3541,30 @@
|
||||
"title": "Configurações",
|
||||
"tool": {
|
||||
"ocr": {
|
||||
"common": {
|
||||
"langs": "Idiomas suportados"
|
||||
},
|
||||
"error": {
|
||||
"not_system": "O OCR do sistema suporta apenas Windows e MacOS"
|
||||
},
|
||||
"image": {
|
||||
"error": {
|
||||
"provider_not_found": "O provedor não existe"
|
||||
},
|
||||
"tesseract": {
|
||||
"langs": "Idiomas suportados",
|
||||
"temp_tooltip": "No momento, apenas chinês e inglês são suportados."
|
||||
"system": {
|
||||
"no_need_configure": "MacOS não requer configuração"
|
||||
},
|
||||
"title": "Imagem"
|
||||
},
|
||||
"image_provider": "Provedor de serviços OCR",
|
||||
"system": {
|
||||
"win": {
|
||||
"langs_tooltip": "Dependendo do Windows para fornecer serviços, você precisa baixar pacotes de idiomas no sistema para dar suporte aos idiomas relevantes."
|
||||
}
|
||||
},
|
||||
"tesseract": {
|
||||
"langs_tooltip": "Leia a documentação para saber quais idiomas personalizados são suportados"
|
||||
},
|
||||
"title": "Serviço OCR"
|
||||
},
|
||||
"preprocess": {
|
||||
@ -3768,6 +3792,7 @@
|
||||
},
|
||||
"empty": "O conteúdo de tradução está vazio",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Modelos Qwen MT não estão disponíveis para uso em conversas. Por favor, vá para a página de tradução.",
|
||||
"detect": {
|
||||
"qwen_mt": "O modelo QwenMT não pode ser usado para detecção de idioma",
|
||||
"unknown": "Idioma desconhecido detectado",
|
||||
@ -3786,6 +3811,7 @@
|
||||
"files": {
|
||||
"drag_text": "Arraste e solte aqui",
|
||||
"error": {
|
||||
"check_type": "Ocorreu um erro ao verificar o tipo de arquivo",
|
||||
"multiple": "Não é permitido fazer upload de vários arquivos",
|
||||
"too_large": "Arquivo muito grande",
|
||||
"unknown": "Falha ao ler o conteúdo do arquivo"
|
||||
@ -3810,7 +3836,7 @@
|
||||
"aborted": "Tradução interrompida"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Digite o texto para traduzir"
|
||||
"placeholder": "Pode colar ou arrastar e soltar texto, arquivos e imagens (suporte a OCR)"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "O idioma de origem é diferente do idioma definido",
|
||||
|
||||
@ -6,9 +6,10 @@ import db from '@renderer/databases'
|
||||
import { getFileFieldLabel } from '@renderer/i18n/label'
|
||||
import { handleDelete, handleRename, sortFiles, tempFilesSort } from '@renderer/services/FileAction'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import store from '@renderer/store'
|
||||
import { FileMetadata, FileTypes } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Button, Empty, Flex, Popconfirm } from 'antd'
|
||||
import { Button, Checkbox, Dropdown, Empty, Flex, Popconfirm } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import {
|
||||
@ -19,7 +20,7 @@ import {
|
||||
FileText,
|
||||
FileType as FileTypeIcon
|
||||
} from 'lucide-react'
|
||||
import { FC, useState } from 'react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -33,6 +34,11 @@ const FilesPage: FC = () => {
|
||||
const [fileType, setFileType] = useState<string>('document')
|
||||
const [sortField, setSortField] = useState<SortField>('created_at')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedFileIds([])
|
||||
}, [fileType])
|
||||
|
||||
const files = useLiveQuery<FileMetadata[]>(() => {
|
||||
if (fileType === 'all') {
|
||||
@ -43,6 +49,44 @@ const FilesPage: FC = () => {
|
||||
|
||||
const sortedFiles = files ? sortFiles(files, sortField, sortOrder) : []
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
const selectedFiles = await Promise.all(selectedFileIds.map((id) => FileManager.getFile(id)))
|
||||
const validFiles = selectedFiles.filter((file) => file !== null && file !== undefined)
|
||||
|
||||
const paintings = store.getState().paintings.paintings
|
||||
const paintingsFiles = paintings.flatMap((p) => p.files)
|
||||
|
||||
const filesInPaintings = validFiles.filter((file) => paintingsFiles.some((p) => p.id === file.id))
|
||||
|
||||
if (filesInPaintings.length > 0) {
|
||||
window.modal.warning({
|
||||
content: t('files.delete.paintings.warning'),
|
||||
centered: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all(selectedFileIds.map((fileId) => handleDelete(fileId, t)))
|
||||
|
||||
setSelectedFileIds([])
|
||||
}
|
||||
|
||||
const handleSelectFile = (fileId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedFileIds((prev) => [...prev, fileId])
|
||||
} else {
|
||||
setSelectedFileIds((prev) => prev.filter((id) => id !== fileId))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedFileIds(sortedFiles.map((file) => file.id))
|
||||
} else {
|
||||
setSelectedFileIds([])
|
||||
}
|
||||
}
|
||||
|
||||
const dataSource = sortedFiles?.map((file) => {
|
||||
return {
|
||||
key: file.id,
|
||||
@ -71,6 +115,13 @@ const FilesPage: FC = () => {
|
||||
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
|
||||
<Button type="text" danger icon={<DeleteIcon size={14} className="lucide-custom" />} />
|
||||
</Popconfirm>
|
||||
{fileType !== 'image' && (
|
||||
<Checkbox
|
||||
checked={selectedFileIds.includes(file.id)}
|
||||
onChange={(e) => handleSelectFile(file.id, e.target.checked)}
|
||||
style={{ margin: '0 8px' }}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@ -102,23 +153,58 @@ const FilesPage: FC = () => {
|
||||
</SideNav>
|
||||
<MainContent>
|
||||
<SortContainer>
|
||||
{(['created_at', 'size', 'name'] as const).map((field) => (
|
||||
<SortButton
|
||||
key={field}
|
||||
active={sortField === field}
|
||||
onClick={() => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field as 'created_at' | 'size' | 'name')
|
||||
setSortOrder('desc')
|
||||
}
|
||||
}}>
|
||||
{getFileFieldLabel(field)}
|
||||
{sortField === field &&
|
||||
(sortOrder === 'desc' ? <ArrowUpWideNarrow size={12} /> : <ArrowDownNarrowWide size={12} />)}
|
||||
</SortButton>
|
||||
))}
|
||||
<Flex gap={8} align="center">
|
||||
{(['created_at', 'size', 'name'] as const).map((field) => (
|
||||
<SortButton
|
||||
key={field}
|
||||
active={sortField === field}
|
||||
onClick={() => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field as 'created_at' | 'size' | 'name')
|
||||
setSortOrder('desc')
|
||||
}
|
||||
}}>
|
||||
{getFileFieldLabel(field)}
|
||||
{sortField === field &&
|
||||
(sortOrder === 'desc' ? <ArrowUpWideNarrow size={12} /> : <ArrowDownNarrowWide size={12} />)}
|
||||
</SortButton>
|
||||
))}
|
||||
</Flex>
|
||||
{fileType !== 'image' && (
|
||||
<Dropdown.Button
|
||||
style={{ width: 'auto' }}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'delete',
|
||||
disabled: selectedFileIds.length === 0,
|
||||
danger: true,
|
||||
label: (
|
||||
<Popconfirm
|
||||
disabled={selectedFileIds.length === 0}
|
||||
title={t('files.delete.title')}
|
||||
description={t('files.delete.content')}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={handleBatchDelete}
|
||||
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
|
||||
{t('files.batch_delete')} ({selectedFileIds.length})
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
]
|
||||
}}
|
||||
trigger={['click']}>
|
||||
<Checkbox
|
||||
indeterminate={selectedFileIds.length > 0 && selectedFileIds.length < sortedFiles.length}
|
||||
checked={selectedFileIds.length === sortedFiles.length && sortedFiles.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}>
|
||||
{t('files.batch_operation')}
|
||||
</Checkbox>
|
||||
</Dropdown.Button>
|
||||
)}
|
||||
</SortContainer>
|
||||
{dataSource && dataSource?.length > 0 ? (
|
||||
<FileList id={fileType} list={dataSource} files={sortedFiles} />
|
||||
@ -147,6 +233,7 @@ const MainContent = styled.div`
|
||||
const SortContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
|
||||
@ -2,14 +2,17 @@ import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
||||
import { Tooltip } from 'antd'
|
||||
import React, { memo, useCallback, useMemo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const CitationSchema = z.object({
|
||||
url: z.string().url(),
|
||||
title: z.string().optional(),
|
||||
content: z.string().optional()
|
||||
})
|
||||
|
||||
interface CitationTooltipProps {
|
||||
children: React.ReactNode
|
||||
citation: {
|
||||
url: string
|
||||
title?: string
|
||||
content?: string
|
||||
}
|
||||
citation: z.infer<typeof CitationSchema>
|
||||
}
|
||||
|
||||
const CitationTooltip: React.FC<CitationTooltipProps> = ({ children, citation }) => {
|
||||
|
||||
65
src/renderer/src/pages/home/Markdown/Hyperlink.tsx
Normal file
65
src/renderer/src/pages/home/Markdown/Hyperlink.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
||||
import { Popover } from 'antd'
|
||||
import React, { memo, useMemo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface HyperLinkProps {
|
||||
children: React.ReactNode
|
||||
href: string
|
||||
}
|
||||
const Hyperlink: React.FC<HyperLinkProps> = ({ children, href }) => {
|
||||
const link = useMemo(() => {
|
||||
try {
|
||||
return decodeURIComponent(href)
|
||||
} catch {
|
||||
return href
|
||||
}
|
||||
}, [href])
|
||||
|
||||
const hostname = useMemo(() => {
|
||||
try {
|
||||
return new URL(link).hostname
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [link])
|
||||
|
||||
if (!href) return children
|
||||
|
||||
return (
|
||||
<Popover
|
||||
arrow={false}
|
||||
content={
|
||||
<StyledHyperLink>
|
||||
{hostname && <Favicon hostname={hostname} alt={link} />}
|
||||
<span>{link}</span>
|
||||
</StyledHyperLink>
|
||||
}
|
||||
placement="top"
|
||||
color="var(--color-background)"
|
||||
styles={{
|
||||
body: {
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '12px',
|
||||
borderRadius: '8px'
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledHyperLink = styled.div`
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
span {
|
||||
max-width: min(400px, 70vw);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(Hyperlink)
|
||||
@ -1,19 +1,23 @@
|
||||
import { parseJSON } from '@renderer/utils/json'
|
||||
import { findCitationInChildren } from '@renderer/utils/markdown'
|
||||
import { isEmpty, omit } from 'lodash'
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import type { Node } from 'unist'
|
||||
|
||||
import CitationTooltip from './CitationTooltip'
|
||||
import CitationTooltip, { CitationSchema } from './CitationTooltip'
|
||||
import Hyperlink from './Hyperlink'
|
||||
|
||||
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
node?: Omit<Node, 'type'>
|
||||
citationData?: {
|
||||
url: string
|
||||
title?: string
|
||||
content?: string
|
||||
}
|
||||
}
|
||||
|
||||
const Link: React.FC<LinkProps> = (props) => {
|
||||
const citationData = useMemo(() => {
|
||||
const raw = parseJSON(findCitationInChildren(props.children))
|
||||
const parsed = CitationSchema.safeParse(raw)
|
||||
return parsed.success ? parsed.data : null
|
||||
}, [props.children])
|
||||
|
||||
// 处理内部链接
|
||||
if (props.href?.startsWith('#')) {
|
||||
return <span className="link">{props.children}</span>
|
||||
@ -28,9 +32,9 @@ const Link: React.FC<LinkProps> = (props) => {
|
||||
})
|
||||
|
||||
// 如果是引用链接并且有引用数据,则使用CitationTooltip
|
||||
if (isCitation && props.citationData) {
|
||||
if (isCitation && citationData) {
|
||||
return (
|
||||
<CitationTooltip citation={props.citationData}>
|
||||
<CitationTooltip citation={citationData}>
|
||||
<a
|
||||
{...omit(props, ['node', 'citationData'])}
|
||||
href={isEmpty(props.href) ? undefined : props.href}
|
||||
@ -44,12 +48,14 @@ const Link: React.FC<LinkProps> = (props) => {
|
||||
|
||||
// 普通链接
|
||||
return (
|
||||
<a
|
||||
{...omit(props, ['node', 'citationData'])}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Hyperlink href={props.href || ''}>
|
||||
<a
|
||||
{...omit(props, ['node', 'citationData'])}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Hyperlink>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -8,9 +8,8 @@ import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRen
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useSmoothStream } from '@renderer/hooks/useSmoothStream'
|
||||
import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage'
|
||||
import { parseJSON } from '@renderer/utils'
|
||||
import { removeSvgEmptyLines } from '@renderer/utils/formats'
|
||||
import { findCitationInChildren, processLatexBrackets } from '@renderer/utils/markdown'
|
||||
import { processLatexBrackets } from '@renderer/utils/markdown'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { type FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -126,7 +125,7 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
|
||||
|
||||
const components = useMemo(() => {
|
||||
return {
|
||||
a: (props: any) => <Link {...props} citationData={parseJSON(findCitationInChildren(props.children))} />,
|
||||
a: (props: any) => <Link {...props} />,
|
||||
code: (props: any) => <CodeBlock {...props} blockId={block.id} />,
|
||||
table: (props: any) => <Table {...props} blockId={block.id} />,
|
||||
img: (props: any) => <ImageViewer style={{ maxWidth: 500, maxHeight: 500 }} {...props} />,
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Hyperlink from '../Hyperlink'
|
||||
|
||||
// 3.1: 使用 vi.hoisted 集中管理模拟
|
||||
const mocks = vi.hoisted(() => ({
|
||||
Popover: ({ children, content, arrow, placement, color, styles }: any) => (
|
||||
<div
|
||||
data-testid="popover"
|
||||
data-arrow={String(arrow)}
|
||||
data-placement={placement}
|
||||
data-color={color}
|
||||
data-styles={JSON.stringify(styles)}>
|
||||
<div data-testid="popover-content">{content}</div>
|
||||
<div data-testid="popover-children">{children}</div>
|
||||
</div>
|
||||
),
|
||||
Favicon: ({ hostname, alt }: { hostname: string; alt: string }) => (
|
||||
<img data-testid="favicon" data-hostname={hostname} alt={alt} />
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Popover: mocks.Popover
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/Icons/FallbackFavicon', () => ({
|
||||
default: mocks.Favicon
|
||||
}))
|
||||
|
||||
describe('Hyperlink', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should match snapshot for normal url', () => {
|
||||
const { container } = render(
|
||||
<Hyperlink href="https://example.com/path%20with%20space">
|
||||
<span>Child</span>
|
||||
</Hyperlink>
|
||||
)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('should return children directly when href is empty', () => {
|
||||
render(
|
||||
<Hyperlink href="">
|
||||
<span>Only Child</span>
|
||||
</Hyperlink>
|
||||
)
|
||||
expect(screen.queryByTestId('popover')).toBeNull()
|
||||
expect(screen.getByText('Only Child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should decode href and show favicon when hostname exists', () => {
|
||||
render(
|
||||
<Hyperlink href="https://domain.com/a%20b">
|
||||
<span>child</span>
|
||||
</Hyperlink>
|
||||
)
|
||||
|
||||
// Popover wrapper exists
|
||||
const popover = screen.getByTestId('popover')
|
||||
expect(popover).toBeInTheDocument()
|
||||
expect(popover).toHaveAttribute('data-arrow', 'false')
|
||||
expect(popover).toHaveAttribute('data-placement', 'top')
|
||||
|
||||
// Content includes decoded url text and favicon with hostname
|
||||
expect(screen.getByTestId('favicon')).toHaveAttribute('data-hostname', 'domain.com')
|
||||
expect(screen.getByTestId('favicon')).toHaveAttribute('alt', 'https://domain.com/a b')
|
||||
expect(screen.getByTestId('popover-content')).toHaveTextContent('https://domain.com/a b')
|
||||
})
|
||||
|
||||
it('should not render favicon when URL parsing fails (invalid url)', () => {
|
||||
render(
|
||||
<Hyperlink href="not%2Furl">
|
||||
<span>child</span>
|
||||
</Hyperlink>
|
||||
)
|
||||
|
||||
// decodeURIComponent succeeds => "not/url" is displayed
|
||||
expect(screen.queryByTestId('favicon')).toBeNull()
|
||||
expect(screen.getByTestId('popover-content')).toHaveTextContent('not/url')
|
||||
})
|
||||
|
||||
it('should not render favicon for non-http(s) scheme without hostname (mailto:)', () => {
|
||||
render(
|
||||
<Hyperlink href="mailto:test%40example.com">
|
||||
<span>child</span>
|
||||
</Hyperlink>
|
||||
)
|
||||
|
||||
// Decoded to mailto:test@example.com, hostname is empty => no favicon
|
||||
expect(screen.queryByTestId('favicon')).toBeNull()
|
||||
expect(screen.getByTestId('popover-content')).toHaveTextContent('mailto:test@example.com')
|
||||
})
|
||||
})
|
||||
127
src/renderer/src/pages/home/Markdown/__tests__/Link.test.tsx
Normal file
127
src/renderer/src/pages/home/Markdown/__tests__/Link.test.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Link from '../Link'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
parseJSON: vi.fn(),
|
||||
findCitationInChildren: vi.fn(),
|
||||
CitationTooltip: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="citation-tooltip">{children}</div>
|
||||
),
|
||||
CitationSchema: {
|
||||
safeParse: vi.fn((input: any) => ({ success: !!input, data: input }))
|
||||
},
|
||||
Hyperlink: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<div data-testid="hyperlink" data-href={href}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/json', () => ({
|
||||
parseJSON: mocks.parseJSON
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/markdown', () => ({
|
||||
findCitationInChildren: mocks.findCitationInChildren
|
||||
}))
|
||||
|
||||
vi.mock('../CitationTooltip', () => ({
|
||||
default: mocks.CitationTooltip,
|
||||
CitationSchema: mocks.CitationSchema
|
||||
}))
|
||||
|
||||
vi.mock('../Hyperlink', () => ({
|
||||
default: mocks.Hyperlink
|
||||
}))
|
||||
|
||||
describe('Link', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const { container } = render(<Link href="https://example.com">Example</Link>)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('should render internal anchor as span.link and no <a>', () => {
|
||||
const { container } = render(<Link href="#section-1">Go to section</Link>)
|
||||
expect(container.querySelector('span.link')).not.toBeNull()
|
||||
expect(container.querySelector('a')).toBeNull()
|
||||
expect(screen.getByText('Go to section')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should wrap with CitationTooltip when children include <sup> and citation data exists', () => {
|
||||
mocks.findCitationInChildren.mockReturnValue('{"title":"ref"}')
|
||||
mocks.parseJSON.mockReturnValue({ title: 'ref' })
|
||||
|
||||
const onParentClick = vi.fn()
|
||||
const { container } = render(
|
||||
<div onClick={onParentClick}>
|
||||
<Link href="https://example.com">
|
||||
<span>ref</span>
|
||||
<sup>1</sup>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('citation-tooltip')).toBeInTheDocument()
|
||||
|
||||
const anchor = container.querySelector('a') as HTMLAnchorElement
|
||||
expect(anchor).not.toBeNull()
|
||||
expect(anchor.getAttribute('target')).toBe('_blank')
|
||||
expect(anchor.getAttribute('rel')).toBe('noreferrer')
|
||||
|
||||
fireEvent.click(anchor)
|
||||
expect(onParentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fall back to Hyperlink when <sup> exists but citation data is null', () => {
|
||||
mocks.findCitationInChildren.mockReturnValue('{"title":"ref"}')
|
||||
mocks.parseJSON.mockReturnValue(null)
|
||||
|
||||
render(
|
||||
<Link href="https://example.com">
|
||||
<span>text</span>
|
||||
<sup>1</sup>
|
||||
</Link>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('hyperlink')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('citation-tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('should render normal external link inside Hyperlink when not a citation', () => {
|
||||
mocks.findCitationInChildren.mockReturnValue(undefined)
|
||||
mocks.parseJSON.mockReturnValue(undefined)
|
||||
|
||||
const { container } = render(<Link href="https://domain.com/path">Open</Link>)
|
||||
|
||||
const wrapper = screen.getByTestId('hyperlink')
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
expect(wrapper).toHaveAttribute('data-href', 'https://domain.com/path')
|
||||
|
||||
const anchor = container.querySelector('a') as HTMLAnchorElement
|
||||
expect(anchor.getAttribute('href')).toBe('https://domain.com/path')
|
||||
expect(anchor.getAttribute('target')).toBe('_blank')
|
||||
expect(anchor.getAttribute('rel')).toBe('noreferrer')
|
||||
})
|
||||
|
||||
it('should omit empty href for citation link (no href attribute when href="")', () => {
|
||||
mocks.findCitationInChildren.mockReturnValue('{"title":"ref"}')
|
||||
mocks.parseJSON.mockReturnValue({ title: 'ref' })
|
||||
|
||||
const { container } = render(
|
||||
<Link href="">
|
||||
text<sup>2</sup>
|
||||
</Link>
|
||||
)
|
||||
|
||||
const anchor = container.querySelector('a') as HTMLAnchorElement
|
||||
expect(anchor).not.toBeNull()
|
||||
expect(anchor.hasAttribute('href')).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,51 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Hyperlink > should match snapshot for normal url 1`] = `
|
||||
.c0 {
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.c0 span {
|
||||
max-width: min(400px,70vw);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
<div>
|
||||
<div
|
||||
data-arrow="false"
|
||||
data-color="var(--color-background)"
|
||||
data-placement="top"
|
||||
data-styles="{"body":{"border":"1px solid var(--color-border)","padding":"12px","borderRadius":"8px"}}"
|
||||
data-testid="popover"
|
||||
>
|
||||
<div
|
||||
data-testid="popover-content"
|
||||
>
|
||||
<div
|
||||
class="c0"
|
||||
>
|
||||
<img
|
||||
alt="https://example.com/path with space"
|
||||
data-hostname="example.com"
|
||||
data-testid="favicon"
|
||||
/>
|
||||
<span>
|
||||
https://example.com/path with space
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="popover-children"
|
||||
>
|
||||
<span>
|
||||
Child
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -0,0 +1,18 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Link > should match snapshot 1`] = `
|
||||
<div>
|
||||
<div
|
||||
data-href="https://example.com"
|
||||
data-testid="hyperlink"
|
||||
>
|
||||
<a
|
||||
href="https://example.com"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Example
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -107,7 +107,7 @@ const MessageItem: FC<Props> = ({
|
||||
logger.error('Failed to resend message:', error as Error)
|
||||
}
|
||||
},
|
||||
[message, resendUserMessageWithEdit, assistant, stopEditing, topic.prompt]
|
||||
[message, resendUserMessageWithEdit, assistant, stopEditing]
|
||||
)
|
||||
|
||||
const handleEditCancel = useCallback(() => {
|
||||
|
||||
@ -20,7 +20,7 @@ import { selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import { TraceIcon } from '@renderer/trace/pages/Component'
|
||||
import type { Assistant, Model, Topic, TranslateLanguage } from '@renderer/types'
|
||||
import { type Message, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, classNames } from '@renderer/utils'
|
||||
import { captureScrollableAsBlob, captureScrollableAsDataURL, classNames } from '@renderer/utils'
|
||||
import { copyMessageAsPlainText } from '@renderer/utils/copy'
|
||||
import {
|
||||
exportMarkdownToJoplin,
|
||||
@ -153,7 +153,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
await resendMessage(messageUpdate ?? message, assistant)
|
||||
}
|
||||
},
|
||||
[assistant, loading, message, resendMessage, topic.prompt]
|
||||
[assistant, loading, message, resendMessage]
|
||||
)
|
||||
|
||||
const { startEditing } = useMessageEditing()
|
||||
@ -271,7 +271,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
label: t('chat.topics.copy.image'),
|
||||
key: 'img',
|
||||
onClick: async () => {
|
||||
await captureScrollableDivAsBlob(messageContainerRef, async (blob) => {
|
||||
await captureScrollableAsBlob(messageContainerRef, async (blob) => {
|
||||
if (blob) {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
}
|
||||
@ -282,7 +282,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
label: t('chat.topics.export.image'),
|
||||
key: 'image',
|
||||
onClick: async () => {
|
||||
const imageData = await captureScrollableDivAsDataURL(messageContainerRef)
|
||||
const imageData = await captureScrollableAsDataURL(messageContainerRef)
|
||||
const title = await getMessageTitle(message)
|
||||
if (title && imageData) {
|
||||
window.api.file.saveImage(title, imageData)
|
||||
|
||||
@ -23,8 +23,8 @@ import { saveMessageAndBlocksToDB, updateMessageAndBlocksThunk } from '@renderer
|
||||
import type { Assistant, Topic } from '@renderer/types'
|
||||
import { type Message, MessageBlock, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import {
|
||||
captureScrollableDivAsBlob,
|
||||
captureScrollableDivAsDataURL,
|
||||
captureScrollableAsBlob,
|
||||
captureScrollableAsDataURL,
|
||||
removeSpecialCharactersForFileName,
|
||||
runAsyncFunction
|
||||
} from '@renderer/utils'
|
||||
@ -135,14 +135,14 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
})
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.COPY_TOPIC_IMAGE, async () => {
|
||||
await captureScrollableDivAsBlob(scrollContainerRef, async (blob) => {
|
||||
await captureScrollableAsBlob(scrollContainerRef, async (blob) => {
|
||||
if (blob) {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
}
|
||||
})
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.EXPORT_TOPIC_IMAGE, async () => {
|
||||
const imageData = await captureScrollableDivAsDataURL(scrollContainerRef)
|
||||
const imageData = await captureScrollableAsDataURL(scrollContainerRef)
|
||||
if (imageData) {
|
||||
window.api.file.saveImage(removeSpecialCharactersForFileName(topic.name), imageData)
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { setImageOcrProvider } from '@renderer/store/ocr'
|
||||
import { isImageOcrProvider, OcrProvider } from '@renderer/types'
|
||||
import { ErrorTag } from '@renderer/components/Tags/ErrorTag'
|
||||
import { isMac, isWin } from '@renderer/config/constant'
|
||||
import { useOcrProviders } from '@renderer/hooks/useOcrProvider'
|
||||
import { BuiltinOcrProviderIds, ImageOcrProvider, isImageOcrProvider, OcrProvider } from '@renderer/types'
|
||||
import { Select } from 'antd'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
import { SettingRow, SettingRowTitle } from '..'
|
||||
|
||||
@ -17,17 +17,16 @@ type Props = {
|
||||
|
||||
const OcrImageSettings = ({ setProvider }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const providers = useAppSelector((state) => state.ocr.providers)
|
||||
const imageProvider = useAppSelector((state) => state.ocr.imageProvider)
|
||||
const { providers, imageProvider, getOcrProviderName, setImageProviderId } = useOcrProviders()
|
||||
|
||||
const imageProviders = providers.filter((p) => isImageOcrProvider(p))
|
||||
const dispatch = useDispatch()
|
||||
|
||||
// 挂载时更新外部状态
|
||||
useEffect(() => {
|
||||
setProvider(imageProvider)
|
||||
}, [imageProvider, setProvider])
|
||||
|
||||
const updateImageProvider = (id: string) => {
|
||||
const setImageProvider = (id: string) => {
|
||||
const provider = imageProviders.find((p) => p.id === id)
|
||||
if (!provider) {
|
||||
logger.error(`Failed to find image provider by id: ${id}`)
|
||||
@ -36,22 +35,29 @@ const OcrImageSettings = ({ setProvider }: Props) => {
|
||||
}
|
||||
|
||||
setProvider(provider)
|
||||
dispatch(setImageOcrProvider(provider))
|
||||
setImageProviderId(id)
|
||||
}
|
||||
|
||||
const platformSupport = isMac || isWin
|
||||
const options = useMemo(() => {
|
||||
const platformFilter = platformSupport ? () => true : (p: ImageOcrProvider) => p.id !== BuiltinOcrProviderIds.system
|
||||
return imageProviders.filter(platformFilter).map((p) => ({
|
||||
value: p.id,
|
||||
label: getOcrProviderName(p)
|
||||
}))
|
||||
}, [getOcrProviderName, imageProviders, platformSupport])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.tool.ocr.image_provider')}</SettingRowTitle>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{!platformSupport && <ErrorTag message={t('settings.tool.ocr.error.not_system')} />}
|
||||
<Select
|
||||
value={imageProvider.id}
|
||||
style={{ width: '200px' }}
|
||||
onChange={(id: string) => updateImageProvider(id)}
|
||||
options={imageProviders.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name
|
||||
}))}
|
||||
onChange={(id: string) => setImageProvider(id)}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
// import { loggerService } from '@logger'
|
||||
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
||||
import { isBuiltinOcrProvider, OcrProvider } from '@renderer/types'
|
||||
import { getOcrProviderLogo } from '@renderer/utils/ocr'
|
||||
import { Avatar, Divider, Flex } from 'antd'
|
||||
import { isMac, isWin } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useOcrProviders } from '@renderer/hooks/useOcrProvider'
|
||||
import { isBuiltinOcrProvider, isOcrSystemProvider, OcrProvider } from '@renderer/types'
|
||||
import { Divider, Flex } from 'antd'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingTitle } from '..'
|
||||
import { SettingGroup, SettingTitle } from '..'
|
||||
import { OcrSystemSettings } from './OcrSystemSettings'
|
||||
import { OcrTesseractSettings } from './OcrTesseractSettings'
|
||||
|
||||
// const logger = loggerService.withContext('OcrTesseractSettings')
|
||||
@ -15,12 +18,22 @@ type Props = {
|
||||
}
|
||||
|
||||
const OcrProviderSettings = ({ provider }: Props) => {
|
||||
// const { t } = useTranslation()
|
||||
const getProviderSettings = () => {
|
||||
const { theme: themeMode } = useTheme()
|
||||
const { OcrProviderLogo, getOcrProviderName } = useOcrProviders()
|
||||
|
||||
if (!isWin && !isMac && isOcrSystemProvider(provider)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ProviderSettings = () => {
|
||||
if (isBuiltinOcrProvider(provider)) {
|
||||
switch (provider.id) {
|
||||
case 'tesseract':
|
||||
return <OcrTesseractSettings />
|
||||
case 'system':
|
||||
return <OcrSystemSettings />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
throw new Error('Not supported OCR provider')
|
||||
@ -28,16 +41,18 @@ const OcrProviderSettings = ({ provider }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingGroup theme={themeMode}>
|
||||
<SettingTitle>
|
||||
<Flex align="center" gap={8}>
|
||||
<ProviderLogo shape="square" src={getOcrProviderLogo(provider.id)} size={16} />
|
||||
<ProviderName> {provider.name}</ProviderName>
|
||||
<OcrProviderLogo provider={provider} />
|
||||
<ProviderName> {getOcrProviderName(provider)}</ProviderName>
|
||||
</Flex>
|
||||
</SettingTitle>
|
||||
<Divider style={{ width: '100%', margin: '10px 0' }} />
|
||||
<ErrorBoundary>{getProviderSettings()}</ErrorBoundary>
|
||||
</>
|
||||
<ErrorBoundary>
|
||||
<ProviderSettings />
|
||||
</ErrorBoundary>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@ -45,8 +60,5 @@ const ProviderName = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
`
|
||||
const ProviderLogo = styled(Avatar)`
|
||||
border: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
export default OcrProviderSettings
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { PictureOutlined } from '@ant-design/icons'
|
||||
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { useOcrProviders } from '@renderer/hooks/useOcrProvider'
|
||||
import { OcrProvider } from '@renderer/types'
|
||||
import { Tabs, TabsProps } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
@ -14,7 +14,7 @@ import OcrProviderSettings from './OcrProviderSettings'
|
||||
const OcrSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme: themeMode } = useTheme()
|
||||
const imageProvider = useAppSelector((state) => state.ocr.imageProvider)
|
||||
const { imageProvider } = useOcrProviders()
|
||||
const [provider, setProvider] = useState<OcrProvider>(imageProvider) // since default to image provider
|
||||
|
||||
const tabs: TabsProps['items'] = [
|
||||
@ -33,9 +33,9 @@ const OcrSettings: FC = () => {
|
||||
<SettingDivider />
|
||||
<Tabs defaultActiveKey="image" items={tabs} />
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={themeMode}>
|
||||
<ErrorBoundary>
|
||||
<OcrProviderSettings provider={provider} />
|
||||
</SettingGroup>
|
||||
</ErrorBoundary>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
// import { loggerService } from '@logger'
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import { SuccessTag } from '@renderer/components/Tags/SuccessTag'
|
||||
import { isMac, isWin } from '@renderer/config/constant'
|
||||
import { useOcrProvider } from '@renderer/hooks/useOcrProvider'
|
||||
import useTranslate from '@renderer/hooks/useTranslate'
|
||||
import { BuiltinOcrProviderIds, isOcrSystemProvider, TranslateLanguageCode } from '@renderer/types'
|
||||
import { Flex, Select } from 'antd'
|
||||
import { startTransition, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingRow, SettingRowTitle } from '..'
|
||||
|
||||
// const logger = loggerService.withContext('OcrSystemSettings')
|
||||
|
||||
export const OcrSystemSettings = () => {
|
||||
const { t } = useTranslation()
|
||||
// 和翻译自定义语言耦合了,应该还ok
|
||||
const { translateLanguages } = useTranslate()
|
||||
const { provider, updateConfig } = useOcrProvider(BuiltinOcrProviderIds.system)
|
||||
|
||||
if (!isOcrSystemProvider(provider)) {
|
||||
throw new Error('Not system provider.')
|
||||
}
|
||||
|
||||
if (!isWin && !isMac) {
|
||||
throw new Error('Only Windows and MacOS is supported.')
|
||||
}
|
||||
|
||||
const [langs, setLangs] = useState<TranslateLanguageCode[]>(provider.config?.langs ?? [])
|
||||
|
||||
// currently static
|
||||
const options = useMemo(
|
||||
() =>
|
||||
translateLanguages.map((lang) => ({
|
||||
value: lang.langCode,
|
||||
label: lang.emoji + ' ' + lang.label()
|
||||
})),
|
||||
[translateLanguages]
|
||||
)
|
||||
|
||||
const onChange = useCallback((value: TranslateLanguageCode[]) => {
|
||||
startTransition(() => {
|
||||
setLangs(value)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
updateConfig({ langs })
|
||||
}, [langs, updateConfig])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
<Flex align="center" gap={4}>
|
||||
{t('settings.tool.ocr.common.langs')}
|
||||
{isWin && <InfoTooltip title={t('settings.tool.ocr.system.win.langs_tooltip')} />}
|
||||
</Flex>
|
||||
</SettingRowTitle>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{isMac && <SuccessTag message={t('settings.tool.ocr.image.system.no_need_configure')} />}
|
||||
{isWin && (
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%', minWidth: 200 }}
|
||||
value={langs}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
maxTagCount={1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,8 +1,12 @@
|
||||
// import { loggerService } from '@logger'
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import CustomTag from '@renderer/components/Tags/CustomTag'
|
||||
import { TESSERACT_LANG_MAP } from '@renderer/config/ocr'
|
||||
import { useOcrProvider } from '@renderer/hooks/useOcrProvider'
|
||||
import { BuiltinOcrProviderIds, isOcrTesseractProvider } from '@renderer/types'
|
||||
import useTranslate from '@renderer/hooks/useTranslate'
|
||||
import { BuiltinOcrProviderIds, isOcrTesseractProvider, TesseractLangCode } from '@renderer/types'
|
||||
import { Flex, Select } from 'antd'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingRow, SettingRowTitle } from '..'
|
||||
@ -11,38 +15,70 @@ import { SettingRow, SettingRowTitle } from '..'
|
||||
|
||||
export const OcrTesseractSettings = () => {
|
||||
const { t } = useTranslation()
|
||||
const { provider } = useOcrProvider(BuiltinOcrProviderIds.tesseract)
|
||||
const { provider, updateConfig } = useOcrProvider(BuiltinOcrProviderIds.tesseract)
|
||||
|
||||
if (!isOcrTesseractProvider(provider)) {
|
||||
throw new Error('Not tesseract provider.')
|
||||
}
|
||||
|
||||
// const [langs, setLangs] = useState<OcrTesseractConfig['langs']>(provider.config?.langs ?? {})
|
||||
const [langs, setLangs] = useState<Partial<Record<TesseractLangCode, boolean>>>(provider.config?.langs ?? {})
|
||||
const { translateLanguages } = useTranslate()
|
||||
|
||||
// currently static
|
||||
const options = [
|
||||
{ value: 'chi_sim', label: t('languages.chinese') },
|
||||
{ value: 'chi_tra', label: t('languages.chinese-traditional') },
|
||||
{ value: 'eng', label: t('languages.english') }
|
||||
]
|
||||
const options = useMemo(
|
||||
() =>
|
||||
translateLanguages
|
||||
.map((lang) => ({
|
||||
value: TESSERACT_LANG_MAP[lang.langCode],
|
||||
label: lang.emoji + ' ' + lang.label()
|
||||
}))
|
||||
.filter((option) => option.value),
|
||||
[translateLanguages]
|
||||
)
|
||||
|
||||
// TODO: type safe objectKeys
|
||||
const value = useMemo(
|
||||
() =>
|
||||
Object.entries(langs)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([lang]) => lang) as TesseractLangCode[],
|
||||
[langs]
|
||||
)
|
||||
|
||||
const onChange = useCallback((values: TesseractLangCode[]) => {
|
||||
setLangs(() => {
|
||||
const newLangs = {}
|
||||
values.forEach((v) => {
|
||||
newLangs[v] = true
|
||||
})
|
||||
return newLangs
|
||||
})
|
||||
}, [])
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
updateConfig({ langs })
|
||||
}, [langs, updateConfig])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
<Flex align="center" gap={4}>
|
||||
{t('settings.tool.ocr.image.tesseract.langs')}
|
||||
<InfoTooltip title={t('settings.tool.ocr.image.tesseract.temp_tooltip')} />
|
||||
{t('settings.tool.ocr.common.langs')}
|
||||
<InfoTooltip title={t('settings.tool.ocr.tesseract.langs_tooltip')} />
|
||||
</Flex>
|
||||
</SettingRowTitle>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
disabled
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Please select"
|
||||
value={['chi_sim', 'chi_tra', 'eng']}
|
||||
style={{ minWidth: 200 }}
|
||||
value={value}
|
||||
options={options}
|
||||
maxTagCount={1}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
// use tag render to disable default close action
|
||||
// don't modify this, because close action won't trigger onBlur to update state
|
||||
tagRender={(props) => <CustomTag color="var(--color-text)">{props.label}</CustomTag>}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { DeleteIcon } from '@renderer/components/Icons'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { getMcpTypeLabel } from '@renderer/i18n/label'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { Button, Switch, Tag, Typography } from 'antd'
|
||||
@ -160,12 +161,19 @@ const ServerDescription = styled.div`
|
||||
height: 50px;
|
||||
`
|
||||
|
||||
const ServerFooter = styled.div`
|
||||
const ServerFooter = styled(Scrollbar)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: flex-start;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
min-height: 22px;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
const ServerTag = styled(Tag)`
|
||||
|
||||
@ -9,7 +9,6 @@ import { DeleteIcon, EditIcon, PoeLogo } from '@renderer/components/Icons'
|
||||
import { getProviderLogo } from '@renderer/config/providers'
|
||||
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { getProviderLabel } from '@renderer/i18n/label'
|
||||
import ImageStorage from '@renderer/services/ImageStorage'
|
||||
import { isSystemProvider, Provider, ProviderType } from '@renderer/types'
|
||||
import {
|
||||
@ -21,8 +20,8 @@ import {
|
||||
matchKeywordsInProvider,
|
||||
uuid
|
||||
} from '@renderer/utils'
|
||||
import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd'
|
||||
import { Eye, EyeOff, GripVertical, PlusIcon, Search, UserPen } from 'lucide-react'
|
||||
import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd'
|
||||
import { GripVertical, PlusIcon, Search, UserPen } from 'lucide-react'
|
||||
import { FC, startTransition, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
@ -31,12 +30,13 @@ import styled from 'styled-components'
|
||||
import AddProviderPopup from './AddProviderPopup'
|
||||
import ModelNotesPopup from './ModelNotesPopup'
|
||||
import ProviderSetting from './ProviderSetting'
|
||||
import UrlSchemaInfoPopup from './UrlSchemaInfoPopup'
|
||||
|
||||
const logger = loggerService.withContext('ProvidersList')
|
||||
const logger = loggerService.withContext('ProviderList')
|
||||
|
||||
const BUTTON_WRAPPER_HEIGHT = 50
|
||||
|
||||
const ProvidersList: FC = () => {
|
||||
const ProviderList: FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const providers = useAllProviders()
|
||||
const { updateProviders, addProvider, removeProvider, updateProvider } = useProviders()
|
||||
@ -99,172 +99,30 @@ const ProvidersList: FC = () => {
|
||||
|
||||
// Handle provider add key from URL schema
|
||||
useEffect(() => {
|
||||
const handleProviderAddKey = (data: {
|
||||
const handleProviderAddKey = async (data: {
|
||||
id: string
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
type?: ProviderType
|
||||
name?: string
|
||||
}) => {
|
||||
const { id, apiKey: newApiKey, baseUrl, type, name } = data
|
||||
const { id } = data
|
||||
|
||||
// 查找匹配的 provider
|
||||
let existingProvider = providers.find((p) => p.id === id)
|
||||
const isNewProvider = !existingProvider
|
||||
const { updatedProvider, isNew, displayName } = await UrlSchemaInfoPopup.show(data)
|
||||
window.navigate(`/settings/provider?id=${id}`)
|
||||
|
||||
if (!existingProvider) {
|
||||
existingProvider = {
|
||||
id,
|
||||
name: name || id,
|
||||
type: type || 'openai',
|
||||
apiKey: '',
|
||||
apiHost: baseUrl || '',
|
||||
models: [],
|
||||
enabled: true,
|
||||
isSystem: false
|
||||
}
|
||||
if (!updatedProvider) {
|
||||
return
|
||||
}
|
||||
|
||||
const providerDisplayName = isSystemProvider(existingProvider)
|
||||
? getProviderLabel(existingProvider.id)
|
||||
: existingProvider.name
|
||||
|
||||
// 检查是否已有 API Key
|
||||
const hasExistingKey = existingProvider.apiKey && existingProvider.apiKey.trim() !== ''
|
||||
|
||||
// 检查新的 API Key 是否已经存在
|
||||
const existingKeys = hasExistingKey ? existingProvider.apiKey.split(',').map((k) => k.trim()) : []
|
||||
const keyAlreadyExists = existingKeys.includes(newApiKey.trim())
|
||||
|
||||
const confirmMessage = keyAlreadyExists
|
||||
? t('settings.models.provider_key_already_exists', {
|
||||
provider: providerDisplayName,
|
||||
key: '*********'
|
||||
})
|
||||
: t('settings.models.provider_key_add_confirm', {
|
||||
provider: providerDisplayName,
|
||||
newKey: '*********'
|
||||
})
|
||||
|
||||
const createModalContent = () => {
|
||||
let showApiKey = false
|
||||
|
||||
const toggleApiKey = () => {
|
||||
showApiKey = !showApiKey
|
||||
// 重新渲染模态框内容
|
||||
updateModalContent()
|
||||
}
|
||||
|
||||
const updateModalContent = () => {
|
||||
const content = (
|
||||
<ProviderInfoContainer>
|
||||
<ProviderInfoCard size="small">
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.provider_name')}:</ProviderInfoLabel>
|
||||
<ProviderInfoValue>{providerDisplayName}</ProviderInfoValue>
|
||||
</ProviderInfoRow>
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.provider_id')}:</ProviderInfoLabel>
|
||||
<ProviderInfoValue>{id}</ProviderInfoValue>
|
||||
</ProviderInfoRow>
|
||||
{baseUrl && (
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.base_url')}:</ProviderInfoLabel>
|
||||
<ProviderInfoValue>{baseUrl}</ProviderInfoValue>
|
||||
</ProviderInfoRow>
|
||||
)}
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.api_key')}:</ProviderInfoLabel>
|
||||
<ApiKeyContainer>
|
||||
<ApiKeyValue>{showApiKey ? newApiKey : '*********'}</ApiKeyValue>
|
||||
<EyeButton onClick={toggleApiKey}>
|
||||
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</EyeButton>
|
||||
</ApiKeyContainer>
|
||||
</ProviderInfoRow>
|
||||
</ProviderInfoCard>
|
||||
<ConfirmMessage>{confirmMessage}</ConfirmMessage>
|
||||
</ProviderInfoContainer>
|
||||
)
|
||||
|
||||
// 更新模态框内容
|
||||
if (modalInstance) {
|
||||
modalInstance.update({
|
||||
content: content
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const modalInstance = window.modal.confirm({
|
||||
title: t('settings.models.provider_key_confirm_title', { provider: providerDisplayName }),
|
||||
content: (
|
||||
<ProviderInfoContainer>
|
||||
<ProviderInfoCard size="small">
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.provider_name')}:</ProviderInfoLabel>
|
||||
<ProviderInfoValue>{providerDisplayName}</ProviderInfoValue>
|
||||
</ProviderInfoRow>
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.provider_id')}:</ProviderInfoLabel>
|
||||
<ProviderInfoValue>{id}</ProviderInfoValue>
|
||||
</ProviderInfoRow>
|
||||
{baseUrl && (
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.base_url')}:</ProviderInfoLabel>
|
||||
<ProviderInfoValue>{baseUrl}</ProviderInfoValue>
|
||||
</ProviderInfoRow>
|
||||
)}
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.api_key')}:</ProviderInfoLabel>
|
||||
<ApiKeyContainer>
|
||||
<ApiKeyValue>{showApiKey ? newApiKey : '*********'}</ApiKeyValue>
|
||||
<EyeButton onClick={toggleApiKey}>
|
||||
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</EyeButton>
|
||||
</ApiKeyContainer>
|
||||
</ProviderInfoRow>
|
||||
</ProviderInfoCard>
|
||||
<ConfirmMessage>{confirmMessage}</ConfirmMessage>
|
||||
</ProviderInfoContainer>
|
||||
),
|
||||
okText: keyAlreadyExists ? t('common.confirm') : t('common.add'),
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onCancel() {
|
||||
window.navigate(`/settings/provider?id=${id}`)
|
||||
},
|
||||
onOk() {
|
||||
window.navigate(`/settings/provider?id=${id}`)
|
||||
if (keyAlreadyExists) {
|
||||
// 如果 key 已经存在,只显示消息,不做任何更改
|
||||
window.message.info(t('settings.models.provider_key_no_change', { provider: providerDisplayName }))
|
||||
return
|
||||
}
|
||||
|
||||
// 如果 key 不存在,添加到现有 keys 的末尾
|
||||
const finalApiKey = hasExistingKey ? `${existingProvider.apiKey},${newApiKey.trim()}` : newApiKey.trim()
|
||||
|
||||
const updatedProvider = {
|
||||
...existingProvider,
|
||||
apiKey: finalApiKey,
|
||||
...(baseUrl && { apiHost: baseUrl })
|
||||
}
|
||||
|
||||
if (isNewProvider) {
|
||||
addProvider(updatedProvider)
|
||||
} else {
|
||||
updateProvider(updatedProvider)
|
||||
}
|
||||
|
||||
setSelectedProvider(updatedProvider)
|
||||
window.message.success(t('settings.models.provider_key_added', { provider: providerDisplayName }))
|
||||
}
|
||||
})
|
||||
|
||||
return modalInstance
|
||||
if (isNew) {
|
||||
addProvider(updatedProvider)
|
||||
} else {
|
||||
updateProvider(updatedProvider)
|
||||
}
|
||||
|
||||
createModalContent()
|
||||
setSelectedProvider(updatedProvider)
|
||||
window.message.success(t('settings.models.provider_key_added', { provider: displayName }))
|
||||
}
|
||||
|
||||
// 检查 URL 参数
|
||||
@ -626,96 +484,4 @@ const AddButtonWrapper = styled.div`
|
||||
padding: 10px 8px;
|
||||
`
|
||||
|
||||
const ProviderInfoContainer = styled.div`
|
||||
color: var(--color-text);
|
||||
`
|
||||
|
||||
const ProviderInfoCard = styled(Card)`
|
||||
margin-bottom: 16px;
|
||||
background-color: var(--color-background-soft);
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
.ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
`
|
||||
|
||||
const ProviderInfoRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`
|
||||
|
||||
const ProviderInfoLabel = styled.span`
|
||||
font-weight: 600;
|
||||
color: var(--color-text-2);
|
||||
min-width: 80px;
|
||||
`
|
||||
|
||||
const ProviderInfoValue = styled.span`
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
background-color: var(--color-background-soft);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
margin-left: 8px;
|
||||
`
|
||||
|
||||
const ConfirmMessage = styled.div`
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
`
|
||||
|
||||
const ApiKeyContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
margin-left: 8px;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const ApiKeyValue = styled.span`
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
background-color: var(--color-background-soft);
|
||||
padding: 2px 32px 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const EyeButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--color-primary-outline);
|
||||
}
|
||||
`
|
||||
|
||||
export default ProvidersList
|
||||
export default ProviderList
|
||||
@ -0,0 +1,165 @@
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useAllProviders } from '@renderer/hooks/useProvider'
|
||||
import { Provider, ProviderType } from '@renderer/types'
|
||||
import { getFancyProviderName, maskApiKey } from '@renderer/utils'
|
||||
import { Button, Descriptions, Flex, Modal } from 'antd'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ShowParams {
|
||||
id: string
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
type?: ProviderType
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface PopupResult {
|
||||
updatedProvider?: Provider
|
||||
isNew: boolean
|
||||
displayName: string
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (result: PopupResult) => void
|
||||
}
|
||||
|
||||
const PopupContainer = ({ id, apiKey: newApiKey, baseUrl, type, name, resolve }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const providers = useAllProviders()
|
||||
const [open, setOpen] = useState(true)
|
||||
const [showFullKey, setShowFullKey] = useState(false)
|
||||
|
||||
const foundProvider = providers.find((p) => p.id === id)
|
||||
const baseProvider: Provider = foundProvider ?? {
|
||||
id,
|
||||
name: name || id,
|
||||
type: type || 'openai',
|
||||
apiKey: '',
|
||||
apiHost: baseUrl || '',
|
||||
models: [],
|
||||
enabled: true,
|
||||
isSystem: false
|
||||
}
|
||||
|
||||
const displayName = getFancyProviderName(baseProvider)
|
||||
const hasExistingKey = baseProvider.apiKey && baseProvider.apiKey.trim() !== ''
|
||||
const existingKeys = hasExistingKey
|
||||
? baseProvider.apiKey
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
const trimmedNewKey = newApiKey.trim()
|
||||
const keyAlreadyExists = existingKeys.includes(trimmedNewKey)
|
||||
const baseUrlChanged = Boolean(baseUrl) && baseUrl !== baseProvider.apiHost
|
||||
const okDisabled = keyAlreadyExists && !baseUrlChanged
|
||||
|
||||
const confirmMessage = keyAlreadyExists
|
||||
? t('settings.models.provider_key_already_exists', { provider: displayName })
|
||||
: t('settings.models.provider_key_add_confirm', { provider: displayName })
|
||||
|
||||
const okText = keyAlreadyExists ? t('common.confirm') : t('common.add')
|
||||
|
||||
const handleOk = () => {
|
||||
setOpen(false)
|
||||
const finalApiKey = keyAlreadyExists
|
||||
? baseProvider.apiKey
|
||||
: hasExistingKey
|
||||
? `${baseProvider.apiKey},${trimmedNewKey}`
|
||||
: trimmedNewKey
|
||||
const finalApiHost = baseUrlChanged ? baseUrl : baseProvider.apiHost
|
||||
|
||||
if (finalApiKey === baseProvider.apiKey && finalApiHost === baseProvider.apiHost) {
|
||||
resolve({ updatedProvider: undefined, isNew: !foundProvider, displayName })
|
||||
return
|
||||
}
|
||||
|
||||
const updatedProvider: Provider = {
|
||||
...baseProvider,
|
||||
apiKey: finalApiKey,
|
||||
apiHost: finalApiHost
|
||||
}
|
||||
resolve({ updatedProvider, isNew: !foundProvider, displayName })
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpen(false)
|
||||
resolve({ updatedProvider: undefined, isNew: !foundProvider, displayName })
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.models.provider_key_confirm_title', { provider: displayName })}
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
okText={okText}
|
||||
okButtonProps={{ disabled: okDisabled }}
|
||||
cancelText={t('common.cancel')}
|
||||
width={500}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<Container>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label={t('settings.models.provider_name')}>{displayName}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('settings.models.provider_id')}>{baseProvider.id}</Descriptions.Item>
|
||||
{baseUrl && <Descriptions.Item label={t('settings.models.base_url')}>{baseUrl}</Descriptions.Item>}
|
||||
<Descriptions.Item label={t('settings.models.api_key')}>
|
||||
<Flex justify="space-between">
|
||||
{showFullKey ? newApiKey : maskApiKey(newApiKey)}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={
|
||||
showFullKey ? (
|
||||
<Eye size={16} color="var(--color-text-3)" />
|
||||
) : (
|
||||
<EyeOff size={16} color="var(--color-text-3)" />
|
||||
)
|
||||
}
|
||||
onClick={() => setShowFullKey((prev) => !prev)}
|
||||
/>
|
||||
</Flex>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<ConfirmMessage>{confirmMessage}</ConfirmMessage>
|
||||
</Container>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
`
|
||||
|
||||
const ConfirmMessage = styled.div`
|
||||
color: var(--color-text);
|
||||
margin-top: 16px;
|
||||
`
|
||||
|
||||
const TopViewKey = 'UrlSchemaInfoPopup'
|
||||
|
||||
export default class UrlSchemaInfoPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<PopupResult>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
this.hide()
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { default as ProviderList } from './ProviderList'
|
||||
@ -30,7 +30,7 @@ import DocProcessSettings from './DocProcessSettings'
|
||||
import GeneralSettings from './GeneralSettings'
|
||||
import MCPSettings from './MCPSettings'
|
||||
import MemorySettings from './MemorySettings'
|
||||
import ProvidersList from './ProviderSettings'
|
||||
import { ProviderList } from './ProviderSettings'
|
||||
import QuickAssistantSettings from './QuickAssistantSettings'
|
||||
import QuickPhraseSettings from './QuickPhraseSettings'
|
||||
import SelectionAssistantSettings from './SelectionAssistantSettings/SelectionAssistantSettings'
|
||||
@ -141,7 +141,7 @@ const SettingsPage: FC = () => {
|
||||
</SettingMenus>
|
||||
<SettingContent>
|
||||
<Routes>
|
||||
<Route path="provider" element={<ProvidersList />} />
|
||||
<Route path="provider" element={<ProviderList />} />
|
||||
<Route path="model" element={<ModelSettings />} />
|
||||
<Route path="websearch" element={<WebSearchSettings />} />
|
||||
<Route path="docprocess" element={<DocProcessSettings />} />
|
||||
|
||||
@ -27,7 +27,7 @@ import {
|
||||
type TranslateHistory,
|
||||
type TranslateLanguage
|
||||
} from '@renderer/types'
|
||||
import { getFileExtension, runAsyncFunction, uuid } from '@renderer/utils'
|
||||
import { getFileExtension, isTextFile, runAsyncFunction, uuid } from '@renderer/utils'
|
||||
import { abortCompletion } from '@renderer/utils/abortController'
|
||||
import { isAbortError } from '@renderer/utils/error'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
@ -177,6 +177,18 @@ const TranslatePage: FC = () => {
|
||||
[dispatch, setTranslatedContent, setTranslating, t, translating]
|
||||
)
|
||||
|
||||
// 控制翻译按钮是否可用
|
||||
const couldTranslate = useMemo(() => {
|
||||
return !(
|
||||
!text.trim() ||
|
||||
(sourceLanguage !== 'auto' && sourceLanguage.langCode === UNKNOWN.langCode) ||
|
||||
targetLanguage.langCode === UNKNOWN.langCode ||
|
||||
(isBidirectional &&
|
||||
(bidirectionalPair[0].langCode === UNKNOWN.langCode || bidirectionalPair[1].langCode === UNKNOWN.langCode)) ||
|
||||
isProcessing
|
||||
)
|
||||
}, [bidirectionalPair, isBidirectional, isProcessing, sourceLanguage, targetLanguage.langCode, text])
|
||||
|
||||
// 控制翻译按钮,翻译前进行校验
|
||||
const onTranslate = useCallback(async () => {
|
||||
if (!couldTranslate) return
|
||||
@ -235,6 +247,7 @@ const TranslatePage: FC = () => {
|
||||
}
|
||||
}, [
|
||||
bidirectionalPair,
|
||||
couldTranslate,
|
||||
getLanguageByLangcode,
|
||||
isBidirectional,
|
||||
setTranslating,
|
||||
@ -446,25 +459,13 @@ const TranslatePage: FC = () => {
|
||||
[]
|
||||
)
|
||||
|
||||
// 控制翻译按钮是否可用
|
||||
const couldTranslate = useMemo(() => {
|
||||
return !(
|
||||
!text.trim() ||
|
||||
(sourceLanguage !== 'auto' && sourceLanguage.langCode === UNKNOWN.langCode) ||
|
||||
targetLanguage.langCode === UNKNOWN.langCode ||
|
||||
(isBidirectional &&
|
||||
(bidirectionalPair[0].langCode === UNKNOWN.langCode || bidirectionalPair[1].langCode === UNKNOWN.langCode)) ||
|
||||
isProcessing
|
||||
)
|
||||
}, [bidirectionalPair, isBidirectional, isProcessing, sourceLanguage, targetLanguage.langCode, text])
|
||||
|
||||
// 控制token估计
|
||||
const tokenCount = useMemo(() => estimateTextTokens(text + prompt), [prompt, text])
|
||||
|
||||
// 统一的文件处理
|
||||
const processFile = useCallback(
|
||||
async (file: FileMetadata) => {
|
||||
// extensible
|
||||
// extensible, only image for now
|
||||
const shouldOCR = isSupportedOcrFile(file)
|
||||
|
||||
if (shouldOCR) {
|
||||
@ -472,23 +473,45 @@ const TranslatePage: FC = () => {
|
||||
const ocrResult = await ocr(file)
|
||||
setText(ocrResult.text)
|
||||
} finally {
|
||||
// do nothing when failed.
|
||||
// do nothing when failed. because error should be handled inside
|
||||
}
|
||||
} else {
|
||||
// the threshold may be too large
|
||||
if (file.size > 5 * MB) {
|
||||
window.message.error(t('translate.files.error.too_large') + ' (0 ~ 5 MB)')
|
||||
} else {
|
||||
try {
|
||||
window.message.loading({ content: t('translate.files.reading'), key: 'translate_files_reading', duration: 0 })
|
||||
let isText: boolean
|
||||
try {
|
||||
const result = await window.api.fs.readText(file.path)
|
||||
setText(result)
|
||||
// 检查文件是否为文本文件
|
||||
isText = await isTextFile(file.path)
|
||||
} catch (e) {
|
||||
logger.error('Failed to read text file.', e as Error)
|
||||
window.message.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e))
|
||||
} finally {
|
||||
window.message.destroy('translate_files_reading')
|
||||
logger.error('Failed to check if file is text.', e as Error)
|
||||
window.message.error(t('translate.files.error.check_type') + ': ' + formatErrorMessage(e))
|
||||
throw e
|
||||
}
|
||||
|
||||
if (!isText) {
|
||||
window.message.error({
|
||||
key: 'file_not_supported',
|
||||
content: t('common.file.not_supported', { type: getFileExtension(file.path) })
|
||||
})
|
||||
logger.error('Unsupported file type.')
|
||||
throw new Error('Unsupported file type')
|
||||
}
|
||||
|
||||
// the threshold may be too large
|
||||
if (file.size > 5 * MB) {
|
||||
window.message.error(t('translate.files.error.too_large') + ' (0 ~ 5 MB)')
|
||||
} else {
|
||||
try {
|
||||
const result = await window.api.fs.readText(file.path)
|
||||
setText(result)
|
||||
} catch (e) {
|
||||
logger.error('Failed to read text file.', e as Error)
|
||||
window.message.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// do nothing when failed because error should be handled inside
|
||||
window.message.destroy('translate_files_reading')
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -532,9 +555,19 @@ const TranslatePage: FC = () => {
|
||||
)
|
||||
|
||||
// 拖动上传文件
|
||||
const {
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleDrop: preventDrop
|
||||
} = useDrag<HTMLDivElement>()
|
||||
|
||||
const onDrop = useCallback(
|
||||
async (e: React.DragEvent<HTMLDivElement>) => {
|
||||
setIsProcessing(true)
|
||||
setIsDragging(false)
|
||||
// const supportedFiles = await filterSupportedFiles(_files, extensions)
|
||||
const data = await getTextFromDropEvent(e).catch((err) => {
|
||||
logger.error('getTextFromDropEvent', err)
|
||||
@ -565,16 +598,9 @@ const TranslatePage: FC = () => {
|
||||
}
|
||||
setIsProcessing(false)
|
||||
},
|
||||
[getSingleFile, processFile, setText, t, text]
|
||||
[getSingleFile, processFile, setIsDragging, setText, t, text]
|
||||
)
|
||||
|
||||
const {
|
||||
isDragging,
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleDrop: preventDrop
|
||||
} = useDrag<HTMLDivElement>()
|
||||
const {
|
||||
isDragging: isDraggingOnInput,
|
||||
handleDragEnter: handleDragEnterInput,
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
MAX_CONTEXT_COUNT,
|
||||
UNLIMITED_CONTEXT_COUNT
|
||||
} from '@renderer/config/constant'
|
||||
import { isQwenMTModel } from '@renderer/config/models'
|
||||
import { UNKNOWN } from '@renderer/config/translate'
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
@ -52,11 +53,10 @@ export function getDefaultAssistant(): Assistant {
|
||||
}
|
||||
|
||||
export function getDefaultTranslateAssistant(targetLanguage: TranslateLanguage, text: string): TranslateAssistant {
|
||||
const translateModel = getTranslateModel()
|
||||
const model = getTranslateModel()
|
||||
const assistant: Assistant = getDefaultAssistant()
|
||||
assistant.model = translateModel
|
||||
|
||||
if (!assistant.model) {
|
||||
if (!model) {
|
||||
logger.error('No translate model')
|
||||
throw new Error(i18n.t('translate.error.not_configured'))
|
||||
}
|
||||
@ -66,15 +66,32 @@ export function getDefaultTranslateAssistant(targetLanguage: TranslateLanguage,
|
||||
throw new Error('Unknown target language')
|
||||
}
|
||||
|
||||
assistant.settings = {
|
||||
const settings = {
|
||||
temperature: 0.7
|
||||
}
|
||||
|
||||
assistant.prompt = store
|
||||
.getState()
|
||||
.settings.translateModelPrompt.replaceAll('{{target_language}}', targetLanguage.value)
|
||||
.replaceAll('{{text}}', text)
|
||||
return { ...assistant, targetLanguage }
|
||||
let prompt: string
|
||||
let content: string
|
||||
if (isQwenMTModel(model)) {
|
||||
content = text
|
||||
prompt = ''
|
||||
} else {
|
||||
content = 'follow system instruction'
|
||||
prompt = store
|
||||
.getState()
|
||||
.settings.translateModelPrompt.replaceAll('{{target_language}}', targetLanguage.value)
|
||||
.replaceAll('{{text}}', text)
|
||||
}
|
||||
|
||||
const translateAssistant = {
|
||||
...assistant,
|
||||
model,
|
||||
settings,
|
||||
prompt,
|
||||
targetLanguage,
|
||||
content
|
||||
} satisfies TranslateAssistant
|
||||
return translateAssistant
|
||||
}
|
||||
|
||||
export function getDefaultAssistantSettings() {
|
||||
|
||||
@ -15,12 +15,7 @@ import { formatErrorMessage, isAbortError } from '@renderer/utils/error'
|
||||
import { t } from 'i18next'
|
||||
|
||||
import { hasApiKey } from './ApiService'
|
||||
import {
|
||||
getDefaultModel,
|
||||
getDefaultTranslateAssistant,
|
||||
getProviderByModel,
|
||||
getTranslateModel
|
||||
} from './AssistantService'
|
||||
import { getDefaultTranslateAssistant, getProviderByModel } from './AssistantService'
|
||||
|
||||
const logger = loggerService.withContext('TranslateService')
|
||||
interface FetchTranslateProps {
|
||||
@ -30,11 +25,7 @@ interface FetchTranslateProps {
|
||||
}
|
||||
|
||||
async function fetchTranslate({ assistant, onResponse, abortKey }: FetchTranslateProps) {
|
||||
const model = getTranslateModel() || assistant.model || getDefaultModel()
|
||||
|
||||
if (!model) {
|
||||
throw new Error(t('translate.error.not_configured'))
|
||||
}
|
||||
const model = assistant.model
|
||||
|
||||
const provider = getProviderByModel(model)
|
||||
|
||||
@ -58,8 +49,8 @@ async function fetchTranslate({ assistant, onResponse, abortKey }: FetchTranslat
|
||||
|
||||
const params: CompletionsParams = {
|
||||
callType: 'translate',
|
||||
messages: 'do',
|
||||
assistant: { ...assistant, model },
|
||||
messages: assistant.content,
|
||||
assistant,
|
||||
streamOutput: stream,
|
||||
enableReasoning,
|
||||
onResponse,
|
||||
|
||||
@ -16,7 +16,7 @@ export const ocr = async (file: SupportedOcrFile, provider: OcrProvider): Promis
|
||||
logger.info(`ocr file ${file.path}`)
|
||||
if (isOcrApiProvider(provider)) {
|
||||
const client = OcrApiClientFactory.create(provider)
|
||||
return client.ocr(file)
|
||||
return client.ocr(file, provider.config)
|
||||
} else {
|
||||
return window.api.ocr.ocr(file, provider)
|
||||
}
|
||||
|
||||
@ -64,7 +64,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 137,
|
||||
version: 138,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -3,7 +3,7 @@ import { nanoid } from '@reduxjs/toolkit'
|
||||
import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE, isMac } from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { isFunctionCallingModel, isNotSupportedTextDelta, SYSTEM_MODELS } from '@renderer/config/models'
|
||||
import { BUILTIN_OCR_PROVIDERS, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr'
|
||||
import { BUILTIN_OCR_PROVIDERS, BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr'
|
||||
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
|
||||
import {
|
||||
isSupportArrayContentProvider,
|
||||
@ -17,6 +17,7 @@ import i18n from '@renderer/i18n'
|
||||
import { DEFAULT_ASSISTANT_SETTINGS } from '@renderer/services/AssistantService'
|
||||
import {
|
||||
Assistant,
|
||||
BuiltinOcrProvider,
|
||||
isSystemProvider,
|
||||
Model,
|
||||
Provider,
|
||||
@ -78,6 +79,13 @@ function addProvider(state: RootState, id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// add ocr provider
|
||||
function addOcrProvider(state: RootState, provider: BuiltinOcrProvider) {
|
||||
if (!state.ocr.providers.find((p) => p.id === provider.id)) {
|
||||
state.ocr.providers.push(provider)
|
||||
}
|
||||
}
|
||||
|
||||
function updateProvider(state: RootState, id: string, provider: Partial<Provider>) {
|
||||
if (state.llm.providers) {
|
||||
const index = state.llm.providers.findIndex((p) => p.id === id)
|
||||
@ -2182,7 +2190,7 @@ const migrateConfig = {
|
||||
try {
|
||||
state.ocr = {
|
||||
providers: BUILTIN_OCR_PROVIDERS,
|
||||
imageProvider: DEFAULT_OCR_PROVIDER.image
|
||||
imageProviderId: DEFAULT_OCR_PROVIDER.image.id
|
||||
}
|
||||
state.translate.translateInput = ''
|
||||
return state
|
||||
@ -2190,6 +2198,15 @@ const migrateConfig = {
|
||||
logger.error('migrate 137 error', error as Error)
|
||||
return state
|
||||
}
|
||||
},
|
||||
'138': (state: RootState) => {
|
||||
try {
|
||||
addOcrProvider(state, BUILTIN_OCR_PROVIDERS_MAP.system)
|
||||
return state
|
||||
} catch (error) {
|
||||
logger.error('migrate 138 error', error as Error)
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,20 +1,25 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { BUILTIN_OCR_PROVIDERS, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr'
|
||||
import { ImageOcrProvider, OcrProvider, OcrProviderConfig } from '@renderer/types'
|
||||
import { OcrProvider, OcrProviderConfig } from '@renderer/types'
|
||||
|
||||
export interface OcrState {
|
||||
providers: OcrProvider[]
|
||||
imageProvider: ImageOcrProvider
|
||||
imageProviderId: string
|
||||
}
|
||||
|
||||
const initialState: OcrState = {
|
||||
providers: BUILTIN_OCR_PROVIDERS,
|
||||
imageProvider: DEFAULT_OCR_PROVIDER.image
|
||||
imageProviderId: DEFAULT_OCR_PROVIDER.image.id
|
||||
}
|
||||
|
||||
const ocrSlice = createSlice({
|
||||
name: 'ocr',
|
||||
initialState,
|
||||
selectors: {
|
||||
getImageProvider(state) {
|
||||
return state.providers.find((p) => p.id === state.imageProviderId)
|
||||
}
|
||||
},
|
||||
reducers: {
|
||||
setOcrProviders(state, action: PayloadAction<OcrProvider[]>) {
|
||||
state.providers = action.payload
|
||||
@ -43,8 +48,8 @@ const ocrSlice = createSlice({
|
||||
Object.assign(state.providers[index].config, action.payload.update)
|
||||
}
|
||||
},
|
||||
setImageOcrProvider(state, action: PayloadAction<ImageOcrProvider>) {
|
||||
state.imageProvider = action.payload
|
||||
setImageOcrProviderId(state, action: PayloadAction<string>) {
|
||||
state.imageProviderId = action.payload
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -55,7 +60,9 @@ export const {
|
||||
removeOcrProvider,
|
||||
updateOcrProvider,
|
||||
updateOcrProviderConfig,
|
||||
setImageOcrProvider
|
||||
setImageOcrProviderId
|
||||
} = ocrSlice.actions
|
||||
|
||||
export const { getImageProvider } = ocrSlice.selectors
|
||||
|
||||
export default ocrSlice.reducer
|
||||
|
||||
@ -105,11 +105,15 @@ export type ImageFileMetadata = FileMetadata & {
|
||||
type: FileTypes.IMAGE
|
||||
}
|
||||
|
||||
export type PdfFileMetadata = FileMetadata & {
|
||||
ext: '.pdf'
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫函数,用于检查一个 FileMetadata 是否为图片文件元数据
|
||||
* @param file - 要检查的文件元数据
|
||||
* @returns 如果文件是图片类型则返回 true
|
||||
*/
|
||||
export const isImageFile = (file: FileMetadata): file is ImageFileMetadata => {
|
||||
export const isImageFileMetadata = (file: FileMetadata): file is ImageFileMetadata => {
|
||||
return file.type === FileTypes.IMAGE
|
||||
}
|
||||
|
||||
@ -36,10 +36,19 @@ export type Assistant = {
|
||||
regularPhrases?: QuickPhrase[] // Added for regular phrase
|
||||
tags?: string[] // 助手标签
|
||||
enableMemory?: boolean
|
||||
// for translate. 更好的做法是定义base assistant,把 Assistant 作为多种不同定义 assistant 的联合类型,但重构代价太大
|
||||
content?: string
|
||||
targetLanguage?: TranslateLanguage
|
||||
}
|
||||
|
||||
export type TranslateAssistant = Assistant & {
|
||||
targetLanguage?: TranslateLanguage
|
||||
model: Model
|
||||
content: string
|
||||
targetLanguage: TranslateLanguage
|
||||
}
|
||||
|
||||
export const isTranslateAssistant = (assistant: Assistant): assistant is TranslateAssistant => {
|
||||
return (assistant.model && assistant.targetLanguage && typeof assistant.content === 'string') !== undefined
|
||||
}
|
||||
|
||||
export type AssistantsSortType = 'tags' | 'list'
|
||||
@ -676,6 +685,7 @@ export type GenerateImageResponse = {
|
||||
}
|
||||
|
||||
// 为了支持自定义语言,设置为string别名
|
||||
/** zh-cn, en-us, etc. */
|
||||
export type TranslateLanguageCode = string
|
||||
|
||||
// langCode应当能够唯一确认一种语言
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import Tesseract from 'tesseract.js'
|
||||
|
||||
import { FileMetadata, ImageFileMetadata, isImageFile } from '.'
|
||||
import { FileMetadata, ImageFileMetadata, isImageFileMetadata, TranslateLanguageCode } from '.'
|
||||
|
||||
export const BuiltinOcrProviderIds = {
|
||||
tesseract: 'tesseract'
|
||||
tesseract: 'tesseract',
|
||||
system: 'system'
|
||||
} as const
|
||||
|
||||
export type BuiltinOcrProviderId = keyof typeof BuiltinOcrProviderIds
|
||||
@ -15,6 +16,7 @@ export const isBuiltinOcrProviderId = (id: string): id is BuiltinOcrProviderId =
|
||||
// extensible
|
||||
export const OcrProviderCapabilities = {
|
||||
image: 'image'
|
||||
// pdf: 'pdf'
|
||||
} as const
|
||||
|
||||
export type OcrProviderCapability = keyof typeof OcrProviderCapabilities
|
||||
@ -63,7 +65,7 @@ export const isOcrProviderApiConfig = (config: unknown): config is OcrProviderAp
|
||||
*
|
||||
* Extend this type to define provider-specific config types.
|
||||
*/
|
||||
export type OcrProviderConfig = {
|
||||
export type OcrProviderBaseConfig = {
|
||||
/** Not used for now. Could safely remove. */
|
||||
api?: OcrProviderApiConfig
|
||||
/** Not used for now. Could safely remove. */
|
||||
@ -72,17 +74,21 @@ export type OcrProviderConfig = {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export type OcrProviderConfig = OcrApiProviderConfig | OcrTesseractConfig | OcrSystemConfig
|
||||
|
||||
export type OcrProvider = {
|
||||
id: string
|
||||
name: string
|
||||
capabilities: OcrProviderCapabilityRecord
|
||||
config?: OcrProviderConfig
|
||||
config?: OcrProviderBaseConfig
|
||||
}
|
||||
|
||||
export type OcrApiProviderConfig = OcrProviderBaseConfig & {
|
||||
api: OcrProviderApiConfig
|
||||
}
|
||||
|
||||
export type OcrApiProvider = OcrProvider & {
|
||||
config: OcrProviderConfig & {
|
||||
api: OcrProviderApiConfig
|
||||
}
|
||||
config: OcrApiProviderConfig
|
||||
}
|
||||
|
||||
export const isOcrApiProvider = (p: OcrProvider): p is OcrApiProvider => {
|
||||
@ -108,6 +114,12 @@ export type ImageOcrProvider = OcrProvider & {
|
||||
}
|
||||
}
|
||||
|
||||
// export type PdfOcrProvider = OcrProvider & {
|
||||
// capabilities: OcrProviderCapabilityRecord & {
|
||||
// [OcrProviderCapabilities.pdf]: true
|
||||
// }
|
||||
// }
|
||||
|
||||
export const isImageOcrProvider = (p: OcrProvider): p is ImageOcrProvider => {
|
||||
return p.capabilities.image === true
|
||||
}
|
||||
@ -115,28 +127,46 @@ export const isImageOcrProvider = (p: OcrProvider): p is ImageOcrProvider => {
|
||||
export type SupportedOcrFile = ImageFileMetadata
|
||||
|
||||
export const isSupportedOcrFile = (file: FileMetadata): file is SupportedOcrFile => {
|
||||
return isImageFile(file)
|
||||
return isImageFileMetadata(file)
|
||||
}
|
||||
|
||||
export type OcrResult = {
|
||||
text: string
|
||||
}
|
||||
|
||||
export type OcrHandler = (file: SupportedOcrFile) => Promise<OcrResult>
|
||||
export type OcrHandler = (file: SupportedOcrFile, options?: OcrProviderBaseConfig) => Promise<OcrResult>
|
||||
|
||||
export type OcrImageHandler = (file: ImageFileMetadata) => Promise<OcrResult>
|
||||
export type OcrImageHandler = (file: ImageFileMetadata, options?: OcrProviderBaseConfig) => Promise<OcrResult>
|
||||
|
||||
// Tesseract Types
|
||||
export type OcrTesseractConfig = OcrProviderConfig & {
|
||||
langs: Partial<Record<TesseractLangCode, boolean>>
|
||||
export type OcrTesseractConfig = OcrProviderBaseConfig & {
|
||||
langs?: Partial<Record<TesseractLangCode, boolean>>
|
||||
}
|
||||
|
||||
export type OcrTesseractProvider = BuiltinOcrProvider & {
|
||||
export type OcrTesseractProvider = {
|
||||
id: 'tesseract'
|
||||
config: OcrTesseractConfig
|
||||
}
|
||||
} & ImageOcrProvider &
|
||||
BuiltinOcrProvider
|
||||
|
||||
export const isOcrTesseractProvider = (p: OcrProvider): p is OcrTesseractProvider => {
|
||||
return p.id === BuiltinOcrProviderIds.tesseract
|
||||
}
|
||||
|
||||
export type TesseractLangCode = Tesseract.LanguageCode
|
||||
|
||||
// System Types
|
||||
export type OcrSystemConfig = OcrProviderBaseConfig & {
|
||||
langs?: TranslateLanguageCode[]
|
||||
}
|
||||
|
||||
export type OcrSystemProvider = {
|
||||
id: 'system'
|
||||
config: OcrSystemConfig
|
||||
} & ImageOcrProvider &
|
||||
// PdfOcrProvider &
|
||||
BuiltinOcrProvider
|
||||
|
||||
export const isOcrSystemProvider = (p: OcrProvider): p is OcrSystemProvider => {
|
||||
return p.id === BuiltinOcrProviderIds.system
|
||||
}
|
||||
|
||||
@ -7,7 +7,8 @@ import {
|
||||
addImageFileToContents,
|
||||
encodeHTML,
|
||||
escapeDollarNumber,
|
||||
extractTitle,
|
||||
extractHtmlTitle,
|
||||
getFileNameFromHtmlTitle,
|
||||
removeSvgEmptyLines,
|
||||
withGenerateImage
|
||||
} from '../formats'
|
||||
@ -179,39 +180,65 @@ describe('formats', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractTitle', () => {
|
||||
describe('extractHtmlTitle', () => {
|
||||
it('should extract title from HTML string', () => {
|
||||
const html = '<html><head><title>Page Title</title></head><body>Content</body></html>'
|
||||
expect(extractTitle(html)).toBe('Page Title')
|
||||
expect(extractHtmlTitle(html)).toBe('Page Title')
|
||||
})
|
||||
|
||||
it('should extract title with case insensitivity', () => {
|
||||
const html = '<html><head><TITLE>Page Title</TITLE></head><body>Content</body></html>'
|
||||
expect(extractTitle(html)).toBe('Page Title')
|
||||
expect(extractHtmlTitle(html)).toBe('Page Title')
|
||||
})
|
||||
|
||||
it('should handle HTML without title tag', () => {
|
||||
const html = '<html><head></head><body>Content</body></html>'
|
||||
expect(extractTitle(html)).toBeNull()
|
||||
expect(extractHtmlTitle(html)).toBe('')
|
||||
})
|
||||
|
||||
it('should handle empty title tag', () => {
|
||||
const html = '<html><head><title></title></head><body>Content</body></html>'
|
||||
expect(extractTitle(html)).toBe('')
|
||||
expect(extractHtmlTitle(html)).toBe('')
|
||||
})
|
||||
|
||||
it('should handle malformed HTML', () => {
|
||||
const html = '<title>Partial HTML'
|
||||
expect(extractTitle(html)).toBe('Partial HTML')
|
||||
expect(extractHtmlTitle(html)).toBe('Partial HTML')
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(extractTitle('')).toBeNull()
|
||||
expect(extractHtmlTitle('')).toBe('')
|
||||
})
|
||||
|
||||
it('should handle undefined', () => {
|
||||
// @ts-ignore for testing
|
||||
expect(extractTitle(undefined)).toBeNull()
|
||||
expect(extractHtmlTitle(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileNameFromHtmlTitle', () => {
|
||||
it('should preserve Chinese characters', () => {
|
||||
expect(getFileNameFromHtmlTitle('中文标题')).toBe('中文标题')
|
||||
expect(getFileNameFromHtmlTitle('中文标题 测试')).toBe('中文标题-测试')
|
||||
})
|
||||
|
||||
it('should preserve alphanumeric characters', () => {
|
||||
expect(getFileNameFromHtmlTitle('Hello123')).toBe('Hello123')
|
||||
expect(getFileNameFromHtmlTitle('Hello World 123')).toBe('Hello-World-123')
|
||||
})
|
||||
|
||||
it('should remove special characters and replace spaces with hyphens', () => {
|
||||
expect(getFileNameFromHtmlTitle('File@Name#Test')).toBe('FileNameTest')
|
||||
expect(getFileNameFromHtmlTitle('File Name Test')).toBe('File-Name-Test')
|
||||
})
|
||||
|
||||
it('should handle mixed languages', () => {
|
||||
expect(getFileNameFromHtmlTitle('中文English123')).toBe('中文English123')
|
||||
expect(getFileNameFromHtmlTitle('中文 English 123')).toBe('中文-English-123')
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(getFileNameFromHtmlTitle('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
captureDiv,
|
||||
captureScrollableDiv,
|
||||
captureScrollableDivAsBlob,
|
||||
captureScrollableDivAsDataURL,
|
||||
captureElement,
|
||||
captureScrollable,
|
||||
captureScrollableAsBlob,
|
||||
captureScrollableAsDataURL,
|
||||
compressImage,
|
||||
convertToBase64,
|
||||
makeSvgSizeAdaptive
|
||||
@ -49,34 +49,34 @@ describe('utils/image', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureDiv', () => {
|
||||
it('should return image data url when divRef.current exists', async () => {
|
||||
describe('captureElement', () => {
|
||||
it('should return image data url when elRef.current exists', async () => {
|
||||
const ref = { current: document.createElement('div') } as React.RefObject<HTMLDivElement>
|
||||
const result = await captureDiv(ref)
|
||||
const result = await captureElement(ref)
|
||||
expect(result).toMatch(/^data:image\/png;base64/)
|
||||
})
|
||||
|
||||
it('should return undefined when divRef.current is null', async () => {
|
||||
it('should return undefined when elRef.current is null', async () => {
|
||||
const ref = { current: null } as unknown as React.RefObject<HTMLDivElement>
|
||||
const result = await captureDiv(ref)
|
||||
const result = await captureElement(ref)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScrollableDiv', () => {
|
||||
it('should return canvas when divRef.current exists', async () => {
|
||||
describe('captureScrollable', () => {
|
||||
it('should return canvas when elRef.current exists', async () => {
|
||||
const div = document.createElement('div')
|
||||
Object.defineProperty(div, 'scrollWidth', { value: 100, configurable: true })
|
||||
Object.defineProperty(div, 'scrollHeight', { value: 100, configurable: true })
|
||||
const ref = { current: div } as React.RefObject<HTMLDivElement>
|
||||
const result = await captureScrollableDiv(ref)
|
||||
const result = await captureScrollable(ref)
|
||||
expect(result).toBeTruthy()
|
||||
expect(typeof (result as HTMLCanvasElement).toDataURL).toBe('function')
|
||||
})
|
||||
|
||||
it('should return undefined when divRef.current is null', async () => {
|
||||
it('should return undefined when elRef.current is null', async () => {
|
||||
const ref = { current: null } as unknown as React.RefObject<HTMLDivElement>
|
||||
const result = await captureScrollableDiv(ref)
|
||||
const result = await captureScrollable(ref)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
@ -85,36 +85,36 @@ describe('utils/image', () => {
|
||||
Object.defineProperty(div, 'scrollWidth', { value: 40000, configurable: true })
|
||||
Object.defineProperty(div, 'scrollHeight', { value: 40000, configurable: true })
|
||||
const ref = { current: div } as React.RefObject<HTMLDivElement>
|
||||
await expect(captureScrollableDiv(ref)).rejects.toBeUndefined()
|
||||
await expect(captureScrollable(ref)).rejects.toBeUndefined()
|
||||
expect(window.message.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScrollableDivAsDataURL', () => {
|
||||
describe('captureScrollableAsDataURL', () => {
|
||||
it('should return data url when canvas exists', async () => {
|
||||
const div = document.createElement('div')
|
||||
Object.defineProperty(div, 'scrollWidth', { value: 100, configurable: true })
|
||||
Object.defineProperty(div, 'scrollHeight', { value: 100, configurable: true })
|
||||
const ref = { current: div } as React.RefObject<HTMLDivElement>
|
||||
const result = await captureScrollableDivAsDataURL(ref)
|
||||
const result = await captureScrollableAsDataURL(ref)
|
||||
expect(result).toMatch(/^data:image\/png;base64/)
|
||||
})
|
||||
|
||||
it('should return undefined when canvas is undefined', async () => {
|
||||
const ref = { current: null } as unknown as React.RefObject<HTMLDivElement>
|
||||
const result = await captureScrollableDivAsDataURL(ref)
|
||||
const result = await captureScrollableAsDataURL(ref)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScrollableDivAsBlob', () => {
|
||||
describe('captureScrollableAsBlob', () => {
|
||||
it('should call func with blob when canvas exists', async () => {
|
||||
const div = document.createElement('div')
|
||||
Object.defineProperty(div, 'scrollWidth', { value: 100, configurable: true })
|
||||
Object.defineProperty(div, 'scrollHeight', { value: 100, configurable: true })
|
||||
const ref = { current: div } as React.RefObject<HTMLDivElement>
|
||||
const func = vi.fn()
|
||||
await captureScrollableDivAsBlob(ref, func)
|
||||
await captureScrollableAsBlob(ref, func)
|
||||
expect(func).toHaveBeenCalled()
|
||||
expect(func.mock.calls[0][0]).toBeInstanceOf(Blob)
|
||||
})
|
||||
@ -122,7 +122,7 @@ describe('utils/image', () => {
|
||||
it('should not call func when canvas is undefined', async () => {
|
||||
const ref = { current: null } as unknown as React.RefObject<HTMLDivElement>
|
||||
const func = vi.fn()
|
||||
await captureScrollableDivAsBlob(ref, func)
|
||||
await captureScrollableAsBlob(ref, func)
|
||||
expect(func).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { FileMetadata } from '@renderer/types'
|
||||
import { KB, MB } from '@shared/config/constant'
|
||||
import { KB, MB, textExts } from '@shared/config/constant'
|
||||
|
||||
/**
|
||||
* 从文件路径中提取目录路径。
|
||||
@ -82,6 +82,11 @@ export async function isSupportedFile(filePath: string, supportExts: Set<string>
|
||||
}
|
||||
}
|
||||
|
||||
export async function isTextFile(filePath: string): Promise<boolean> {
|
||||
const set = new Set(textExts)
|
||||
return isSupportedFile(filePath, set)
|
||||
}
|
||||
|
||||
export async function filterSupportedFiles(files: FileMetadata[], supportExts: string[]): Promise<FileMetadata[]> {
|
||||
const extensionSet = new Set(supportExts)
|
||||
const validationResults = await Promise.all(
|
||||
|
||||
@ -53,8 +53,8 @@ export function escapeDollarNumber(text: string) {
|
||||
return escapedText
|
||||
}
|
||||
|
||||
export function extractTitle(html: string): string | null {
|
||||
if (!html) return null
|
||||
export function extractHtmlTitle(html: string): string {
|
||||
if (!html) return ''
|
||||
|
||||
// 处理标准闭合的标题标签
|
||||
const titleRegex = /<title>(.*?)<\/title>/i
|
||||
@ -72,7 +72,17 @@ export function extractTitle(html: string): string | null {
|
||||
return malformedMatch[1] ? malformedMatch[1].trim() : ''
|
||||
}
|
||||
|
||||
return null
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 HTML 标题中提取文件名(不包含扩展名)
|
||||
* @param title HTML 标题
|
||||
* @returns 文件名
|
||||
*/
|
||||
export function getFileNameFromHtmlTitle(title: string): string {
|
||||
if (!title) return ''
|
||||
return title.replace(/[^\p{L}\p{N}\s]/gu, '').replace(/\s+/g, '-')
|
||||
}
|
||||
|
||||
export function removeSvgEmptyLines(text: string): string {
|
||||
|
||||
@ -33,18 +33,18 @@ export const compressImage = async (file: File): Promise<File> => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获指定 div 元素的图像数据。
|
||||
* @param divRef div 元素的引用
|
||||
* 捕获指定元素的图像数据。
|
||||
* @param elRef 元素的引用
|
||||
* @returns Promise<string | undefined> 图像数据 URL,如果失败则返回 undefined
|
||||
*/
|
||||
export async function captureDiv(divRef: React.RefObject<HTMLDivElement>) {
|
||||
if (divRef.current) {
|
||||
export async function captureElement(elRef: React.RefObject<HTMLElement>) {
|
||||
if (elRef.current) {
|
||||
try {
|
||||
const canvas = await htmlToImage.toCanvas(divRef.current)
|
||||
const canvas = await htmlToImage.toCanvas(elRef.current)
|
||||
const imageData = canvas.toDataURL('image/png')
|
||||
return imageData
|
||||
} catch (error) {
|
||||
logger.error('Error capturing div:', error as Error)
|
||||
logger.error('Error capturing element:', error as Error)
|
||||
return Promise.reject()
|
||||
}
|
||||
}
|
||||
@ -52,50 +52,50 @@ export async function captureDiv(divRef: React.RefObject<HTMLDivElement>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获可滚动 div 元素的完整内容图像。
|
||||
* @param divRef 可滚动 div 元素的引用
|
||||
* 捕获可滚动元素的完整内容图像。
|
||||
* @param elRef 可滚动元素的引用
|
||||
* @returns Promise<HTMLCanvasElement | undefined> 捕获的画布对象,如果失败则返回 undefined
|
||||
*/
|
||||
export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElement | null>) => {
|
||||
if (divRef.current) {
|
||||
export const captureScrollable = async (elRef: React.RefObject<HTMLElement | null>) => {
|
||||
if (elRef.current) {
|
||||
try {
|
||||
const div = divRef.current
|
||||
const el = elRef.current
|
||||
|
||||
// Save original styles
|
||||
const originalStyle = {
|
||||
height: div.style.height,
|
||||
maxHeight: div.style.maxHeight,
|
||||
overflow: div.style.overflow,
|
||||
position: div.style.position
|
||||
height: el.style.height,
|
||||
maxHeight: el.style.maxHeight,
|
||||
overflow: el.style.overflow,
|
||||
position: el.style.position
|
||||
}
|
||||
|
||||
const originalScrollTop = div.scrollTop
|
||||
const originalScrollTop = el.scrollTop
|
||||
|
||||
// Hide scrollbars during capture
|
||||
div.classList.add('hide-scrollbar')
|
||||
el.classList.add('hide-scrollbar')
|
||||
|
||||
// Modify styles to show full content
|
||||
div.style.height = 'auto'
|
||||
div.style.maxHeight = 'none'
|
||||
div.style.overflow = 'visible'
|
||||
div.style.position = 'static'
|
||||
el.style.height = 'auto'
|
||||
el.style.maxHeight = 'none'
|
||||
el.style.overflow = 'visible'
|
||||
el.style.position = 'static'
|
||||
|
||||
// calculate the size of the div
|
||||
const totalWidth = div.scrollWidth
|
||||
const totalHeight = div.scrollHeight
|
||||
// calculate the size of the element
|
||||
const totalWidth = el.scrollWidth
|
||||
const totalHeight = el.scrollHeight
|
||||
|
||||
// check if the size of the div is too large
|
||||
// check if the size of the element is too large
|
||||
const MAX_ALLOWED_DIMENSION = 32767 // the maximum allowed pixel size
|
||||
if (totalHeight > MAX_ALLOWED_DIMENSION || totalWidth > MAX_ALLOWED_DIMENSION) {
|
||||
// restore the original styles
|
||||
div.style.height = originalStyle.height
|
||||
div.style.maxHeight = originalStyle.maxHeight
|
||||
div.style.overflow = originalStyle.overflow
|
||||
div.style.position = originalStyle.position
|
||||
el.style.height = originalStyle.height
|
||||
el.style.maxHeight = originalStyle.maxHeight
|
||||
el.style.overflow = originalStyle.overflow
|
||||
el.style.position = originalStyle.position
|
||||
|
||||
// restore the original scroll position
|
||||
setTimeout(() => {
|
||||
div.scrollTop = originalScrollTop
|
||||
el.scrollTop = originalScrollTop
|
||||
}, 0)
|
||||
|
||||
window.message.error({
|
||||
@ -107,16 +107,16 @@ export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElemen
|
||||
|
||||
const canvas = await new Promise<HTMLCanvasElement>((resolve, reject) => {
|
||||
htmlToImage
|
||||
.toCanvas(div, {
|
||||
backgroundColor: getComputedStyle(div).getPropertyValue('--color-background'),
|
||||
.toCanvas(el, {
|
||||
backgroundColor: getComputedStyle(el).getPropertyValue('--color-background'),
|
||||
cacheBust: true,
|
||||
pixelRatio: window.devicePixelRatio,
|
||||
skipAutoScale: true,
|
||||
canvasWidth: div.scrollWidth,
|
||||
canvasHeight: div.scrollHeight,
|
||||
canvasWidth: el.scrollWidth,
|
||||
canvasHeight: el.scrollHeight,
|
||||
style: {
|
||||
backgroundColor: getComputedStyle(div).backgroundColor,
|
||||
color: getComputedStyle(div).color
|
||||
backgroundColor: getComputedStyle(el).backgroundColor,
|
||||
color: getComputedStyle(el).color
|
||||
}
|
||||
})
|
||||
.then((canvas) => resolve(canvas))
|
||||
@ -124,25 +124,25 @@ export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElemen
|
||||
})
|
||||
|
||||
// Restore original styles
|
||||
div.style.height = originalStyle.height
|
||||
div.style.maxHeight = originalStyle.maxHeight
|
||||
div.style.overflow = originalStyle.overflow
|
||||
div.style.position = originalStyle.position
|
||||
el.style.height = originalStyle.height
|
||||
el.style.maxHeight = originalStyle.maxHeight
|
||||
el.style.overflow = originalStyle.overflow
|
||||
el.style.position = originalStyle.position
|
||||
|
||||
const imageData = canvas
|
||||
|
||||
// Restore original scroll position
|
||||
setTimeout(() => {
|
||||
div.scrollTop = originalScrollTop
|
||||
el.scrollTop = originalScrollTop
|
||||
}, 0)
|
||||
|
||||
return imageData
|
||||
} catch (error) {
|
||||
logger.error('Error capturing scrollable div:', error as Error)
|
||||
logger.error('Error capturing scrollable element:', error as Error)
|
||||
throw error
|
||||
} finally {
|
||||
// Remove scrollbar hiding class
|
||||
divRef.current?.classList.remove('hide-scrollbar')
|
||||
elRef.current?.classList.remove('hide-scrollbar')
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,12 +150,12 @@ export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElemen
|
||||
}
|
||||
|
||||
/**
|
||||
* 将可滚动 div 元素的图像数据转换为 Data URL 格式。
|
||||
* @param divRef 可滚动 div 元素的引用
|
||||
* 将可滚动元素的图像数据转换为 Data URL 格式。
|
||||
* @param elRef 可滚动元素的引用
|
||||
* @returns Promise<string | undefined> 图像数据 URL,如果失败则返回 undefined
|
||||
*/
|
||||
export const captureScrollableDivAsDataURL = async (divRef: React.RefObject<HTMLDivElement | null>) => {
|
||||
return captureScrollableDiv(divRef).then((canvas) => {
|
||||
export const captureScrollableAsDataURL = async (elRef: React.RefObject<HTMLElement | null>) => {
|
||||
return captureScrollable(elRef).then((canvas) => {
|
||||
if (canvas) {
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
@ -164,16 +164,94 @@ export const captureScrollableDivAsDataURL = async (divRef: React.RefObject<HTML
|
||||
}
|
||||
|
||||
/**
|
||||
* 将可滚动 div 元素的图像数据转换为 Blob 格式。
|
||||
* @param divRef 可滚动 div 元素的引用
|
||||
* 将可滚动元素的图像数据转换为 Blob 格式。
|
||||
* @param elRef 可滚动元素的引用
|
||||
* @param func Blob 回调函数
|
||||
* @returns Promise<void> 处理结果
|
||||
*/
|
||||
export const captureScrollableDivAsBlob = async (
|
||||
divRef: React.RefObject<HTMLDivElement | null>,
|
||||
export const captureScrollableAsBlob = async (elRef: React.RefObject<HTMLElement | null>, func: BlobCallback) => {
|
||||
await captureScrollable(elRef).then((canvas) => {
|
||||
canvas?.toBlob(func, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获 iframe 内部文档的完整内容快照
|
||||
*/
|
||||
export async function captureScrollableIframe(
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>
|
||||
): Promise<HTMLCanvasElement | undefined> {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe) return Promise.resolve(undefined)
|
||||
|
||||
const doc = iframe.contentDocument
|
||||
const win = doc?.defaultView
|
||||
if (!doc || !win) return Promise.resolve(undefined)
|
||||
|
||||
// 等待两帧渲染稳定
|
||||
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())))
|
||||
|
||||
// 触发懒加载资源尽快加载
|
||||
doc.querySelectorAll('img[loading="lazy"]').forEach((img) => img.setAttribute('loading', 'eager'))
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
|
||||
const de = doc.documentElement
|
||||
const b = doc.body
|
||||
|
||||
// 计算完整尺寸
|
||||
const totalWidth = Math.max(b.scrollWidth, de.scrollWidth, b.clientWidth, de.clientWidth)
|
||||
const totalHeight = Math.max(b.scrollHeight, de.scrollHeight, b.clientHeight, de.clientHeight)
|
||||
|
||||
logger.verbose('The iframe to be captured has size:', { totalWidth, totalHeight })
|
||||
|
||||
// 按比例缩放以不超过上限
|
||||
const MAX = 32767
|
||||
const maxSide = Math.max(totalWidth, totalHeight)
|
||||
const scale = maxSide > MAX ? MAX / maxSide : 1
|
||||
const pixelRatio = (win.devicePixelRatio || 1) * scale
|
||||
|
||||
const bg = win.getComputedStyle(b).backgroundColor || '#ffffff'
|
||||
const fg = win.getComputedStyle(b).color || '#000000'
|
||||
|
||||
try {
|
||||
const canvas = await htmlToImage.toCanvas(de, {
|
||||
backgroundColor: bg,
|
||||
cacheBust: true,
|
||||
pixelRatio,
|
||||
skipAutoScale: true,
|
||||
width: Math.floor(totalWidth),
|
||||
height: Math.floor(totalHeight),
|
||||
style: {
|
||||
backgroundColor: bg,
|
||||
color: fg,
|
||||
width: `${totalWidth}px`,
|
||||
height: `${totalHeight}px`,
|
||||
overflow: 'visible',
|
||||
display: 'block'
|
||||
}
|
||||
})
|
||||
|
||||
return canvas
|
||||
} catch (error) {
|
||||
logger.error('Error capturing iframe full snapshot:', error as Error)
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export const captureScrollableIframeAsDataURL = async (iframeRef: React.RefObject<HTMLIFrameElement | null>) => {
|
||||
return captureScrollableIframe(iframeRef).then((canvas) => {
|
||||
if (canvas) {
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
}
|
||||
|
||||
export const captureScrollableIframeAsBlob = async (
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>,
|
||||
func: BlobCallback
|
||||
) => {
|
||||
await captureScrollableDiv(divRef).then((canvas) => {
|
||||
await captureScrollableIframe(iframeRef).then((canvas) => {
|
||||
canvas?.toBlob(func, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png'
|
||||
import { isBuiltinOcrProviderId } from '@renderer/types'
|
||||
|
||||
export function getOcrProviderLogo(providerId: string) {
|
||||
if (isBuiltinOcrProviderId(providerId)) {
|
||||
switch (providerId) {
|
||||
case 'tesseract':
|
||||
return TesseractLogo
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
323
yarn.lock
323
yarn.lock
@ -2135,13 +2135,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.9.2":
|
||||
"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0":
|
||||
version: 7.27.4
|
||||
resolution: "@babel/runtime@npm:7.27.4"
|
||||
checksum: 10c0/ca99e964179c31615e1352e058cc9024df7111c829631c90eec84caba6703cc32acc81503771847c306b3c70b815609fe82dde8682936debe295b0b283b2dc6e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.26.7":
|
||||
version: 7.28.3
|
||||
resolution: "@babel/runtime@npm:7.28.3"
|
||||
checksum: 10c0/b360f82c2c5114f2a062d4d143d7b4ec690094764853937110585a9497977aed66c102166d0e404766c274e02a50ffb8f6d77fef7251ecf3f607f0e03e6397bc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7":
|
||||
version: 7.28.2
|
||||
resolution: "@babel/runtime@npm:7.28.2"
|
||||
@ -3719,21 +3726,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@hello-pangea/dnd@npm:^16.6.0":
|
||||
version: 16.6.0
|
||||
resolution: "@hello-pangea/dnd@npm:16.6.0"
|
||||
"@hello-pangea/dnd@npm:^18.0.1":
|
||||
version: 18.0.1
|
||||
resolution: "@hello-pangea/dnd@npm:18.0.1"
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.24.1"
|
||||
"@babel/runtime": "npm:^7.26.7"
|
||||
css-box-model: "npm:^1.2.1"
|
||||
memoize-one: "npm:^6.0.0"
|
||||
raf-schd: "npm:^4.0.3"
|
||||
react-redux: "npm:^8.1.3"
|
||||
redux: "npm:^4.2.1"
|
||||
use-memo-one: "npm:^1.1.3"
|
||||
react-redux: "npm:^9.2.0"
|
||||
redux: "npm:^5.0.1"
|
||||
peerDependencies:
|
||||
react: ^16.8.5 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/ef43ba21f063f6497f399b457452d45be456b1f28405b148d9683d2ca65e5f77e2685a0b7e9998aaca4f8676b1642ba2c277fc78643ea59fd6b9f71a56ffc5e0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/30c47ac8048f85e5c6d39c0b5a492cf2cc9e5f532cee12c5ecc77688596c8846670be142bd716212db789f161cd769601a5da135fa99ac65824fbb6a07d4d137
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -4948,6 +4953,55 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/system-ocr-darwin-arm64@npm:1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "@napi-rs/system-ocr-darwin-arm64@npm:1.0.2"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/system-ocr-darwin-x64@npm:1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "@napi-rs/system-ocr-darwin-x64@npm:1.0.2"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/system-ocr-win32-arm64-msvc@npm:1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "@napi-rs/system-ocr-win32-arm64-msvc@npm:1.0.2"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/system-ocr-win32-x64-msvc@npm:1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "@napi-rs/system-ocr-win32-x64-msvc@npm:1.0.2"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/system-ocr@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "@napi-rs/system-ocr@npm:1.0.2"
|
||||
dependencies:
|
||||
"@napi-rs/system-ocr-darwin-arm64": "npm:1.0.2"
|
||||
"@napi-rs/system-ocr-darwin-x64": "npm:1.0.2"
|
||||
"@napi-rs/system-ocr-win32-arm64-msvc": "npm:1.0.2"
|
||||
"@napi-rs/system-ocr-win32-x64-msvc": "npm:1.0.2"
|
||||
dependenciesMeta:
|
||||
"@napi-rs/system-ocr-darwin-arm64":
|
||||
optional: true
|
||||
"@napi-rs/system-ocr-darwin-x64":
|
||||
optional: true
|
||||
"@napi-rs/system-ocr-win32-arm64-msvc":
|
||||
optional: true
|
||||
"@napi-rs/system-ocr-win32-x64-msvc":
|
||||
optional: true
|
||||
checksum: 10c0/170f89051d2b9da52648ef933ab5e73bbafcb7ffb98948d877d3c9718e308ebf258b7b947b0d4f2bfe65fd8d2adf5acf47e5f7efd59ac0535ca99562e41b833a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/wasm-runtime@npm:^0.2.4":
|
||||
version: 0.2.12
|
||||
resolution: "@napi-rs/wasm-runtime@npm:0.2.12"
|
||||
@ -6005,79 +6059,79 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/core@npm:3.9.1":
|
||||
version: 3.9.1
|
||||
resolution: "@shikijs/core@npm:3.9.1"
|
||||
"@shikijs/core@npm:3.12.0":
|
||||
version: 3.12.0
|
||||
resolution: "@shikijs/core@npm:3.12.0"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.9.1"
|
||||
"@shikijs/types": "npm:3.12.0"
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
"@types/hast": "npm:^3.0.4"
|
||||
hast-util-to-html: "npm:^9.0.5"
|
||||
checksum: 10c0/2267cb9b056f29d93d60b5591340161db614719f1cee8e0050af8ca048eb8ee32bac51fcfe536de65dcaeadae8697fba1157c178803daae33771a2baf6bf9672
|
||||
checksum: 10c0/c3b0816d412cb39844348974840ca4dcbd8b2932127e6834b94068479b75adb79670027e0e41c70955a4f433dcf9ec2ee620e3c87380aa7944d249352e46e4e6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/engine-javascript@npm:3.9.1":
|
||||
version: 3.9.1
|
||||
resolution: "@shikijs/engine-javascript@npm:3.9.1"
|
||||
"@shikijs/engine-javascript@npm:3.12.0":
|
||||
version: 3.12.0
|
||||
resolution: "@shikijs/engine-javascript@npm:3.12.0"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.9.1"
|
||||
"@shikijs/types": "npm:3.12.0"
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
oniguruma-to-es: "npm:^4.3.3"
|
||||
checksum: 10c0/9d5e5e0fde46c9fc3813363f61b75cee9b06df10a676609b2006df344123993af94444f7564e44adb877c8299a33fa144c0bf35688370d0a70077249c2a5836b
|
||||
checksum: 10c0/8dfed4f6ff4d33f875e05bc80edd42403785f7eb56d435f54b901e516addb50ff6ba9cc87b7e4dd3c3067fb183dc3edd25677cdc88c5bfb65b6c5e9536c7cb87
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/engine-oniguruma@npm:3.9.1":
|
||||
version: 3.9.1
|
||||
resolution: "@shikijs/engine-oniguruma@npm:3.9.1"
|
||||
"@shikijs/engine-oniguruma@npm:3.12.0":
|
||||
version: 3.12.0
|
||||
resolution: "@shikijs/engine-oniguruma@npm:3.12.0"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.9.1"
|
||||
"@shikijs/types": "npm:3.12.0"
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
checksum: 10c0/70eb64cccb043d01f82804a0c630ce1861ab9cb0f79eca31ea550c1f9c6e7de2f37094c4c28f0fca81b26d78b77287d11c110809e7f76a59829c443abd88ef2c
|
||||
checksum: 10c0/01bc3f6a8429d10928ad96d6e4f1645954b179d02aa687214409405a19a488421b8375c50636607aadd52865690ca3fcf3b7c46d0e0af15918b6226332eab995
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/langs@npm:3.9.1":
|
||||
version: 3.9.1
|
||||
resolution: "@shikijs/langs@npm:3.9.1"
|
||||
"@shikijs/langs@npm:3.12.0":
|
||||
version: 3.12.0
|
||||
resolution: "@shikijs/langs@npm:3.12.0"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.9.1"
|
||||
checksum: 10c0/94351ef82e0a7a26351eaf70e33a5c0a48727ef052b907cb3c09ebbd3bb8fb1ef7825ae27c0ff2829888d5fb9da24eeca86c914178c354754eefd7fab70a613f
|
||||
"@shikijs/types": "npm:3.12.0"
|
||||
checksum: 10c0/eb221370ea5c11488c7709ca2f69994d5b981e30ddf7da70deb111ab5ddda9de3dfea063cc647ff137015dc271e612268da59ed07b2b4e1f2661f82828556fe7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/markdown-it@npm:^3.9.1":
|
||||
version: 3.9.1
|
||||
resolution: "@shikijs/markdown-it@npm:3.9.1"
|
||||
"@shikijs/markdown-it@npm:^3.12.0":
|
||||
version: 3.12.0
|
||||
resolution: "@shikijs/markdown-it@npm:3.12.0"
|
||||
dependencies:
|
||||
markdown-it: "npm:^14.1.0"
|
||||
shiki: "npm:3.9.1"
|
||||
shiki: "npm:3.12.0"
|
||||
peerDependencies:
|
||||
markdown-it-async: ^2.2.0
|
||||
peerDependenciesMeta:
|
||||
markdown-it-async:
|
||||
optional: true
|
||||
checksum: 10c0/54b7acbf1e12b8686a71fe22b988e1a1475d70bdca5434824f2cb75efc5fc929d9be793c7118e3d9a112589d39197e954b8d47dddbfc1e6981b05b5b1a28d98a
|
||||
checksum: 10c0/5b9cc0c1da05084923f7496c3a4b097819d542e9d0cb9bd648fbb157660a3e3c560184d7956d5eaf81ebb055e07646cac13e1b6bd426ac4feaa0fcf66227eb0e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/themes@npm:3.9.1":
|
||||
version: 3.9.1
|
||||
resolution: "@shikijs/themes@npm:3.9.1"
|
||||
"@shikijs/themes@npm:3.12.0":
|
||||
version: 3.12.0
|
||||
resolution: "@shikijs/themes@npm:3.12.0"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.9.1"
|
||||
checksum: 10c0/a061eec4d9dd147d83cda9c41b296263fab92d6113146279a244751b9f016f8af543f91c37dcefe33f47cff9f1a1d7898f78a80169947ac119617b32d16766d4
|
||||
"@shikijs/types": "npm:3.12.0"
|
||||
checksum: 10c0/0e24e9effede6ea25be0c1d5c74ce4e24953a46884a2718bb5326716b0919191a3e39c37c8456309a3d50a8e8611cf1caf9d48649a311e48ee5298afb00b4663
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/types@npm:3.9.1":
|
||||
version: 3.9.1
|
||||
resolution: "@shikijs/types@npm:3.9.1"
|
||||
"@shikijs/types@npm:3.12.0":
|
||||
version: 3.12.0
|
||||
resolution: "@shikijs/types@npm:3.12.0"
|
||||
dependencies:
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
"@types/hast": "npm:^3.0.4"
|
||||
checksum: 10c0/c726478ae36ca078a8b9d61a9b51b83fe32b7af2cfe7ae597828b2ffccbd24858d955c49d0786af13ebd04cfbb9d192067499c410a05c41eb38da57928424076
|
||||
checksum: 10c0/b946ce2995cca3e714170a5fb4386de18f9d8aa9a3bc5de47daaa9d63116f5075930cfb137d69c03fd5d62147ab9ecbf44d0c06a1b8bf5c214da0273ad920b16
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -7389,21 +7443,21 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/query-core@npm:5.75.5":
|
||||
version: 5.75.5
|
||||
resolution: "@tanstack/query-core@npm:5.75.5"
|
||||
checksum: 10c0/3627f9580df5b4a032ff830c1b1f6fa55ae754962a6aaab4ee79607917c9fbd98ce8f7302cc4c88e5b19fd6378689163eb42f32a83c05fe2dc81e7899c94d0df
|
||||
"@tanstack/query-core@npm:5.85.5":
|
||||
version: 5.85.5
|
||||
resolution: "@tanstack/query-core@npm:5.85.5"
|
||||
checksum: 10c0/344670ac117bc4775a9e812fc91e27befc9ef4f681e341d8f76af3cd075eecdcc5aa058a9544a6b56cccb8e07f22ef5c9e0c852331d428bcce5e1223deef14bd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/react-query@npm:^5.27.0":
|
||||
version: 5.75.5
|
||||
resolution: "@tanstack/react-query@npm:5.75.5"
|
||||
"@tanstack/react-query@npm:^5.85.5":
|
||||
version: 5.85.5
|
||||
resolution: "@tanstack/react-query@npm:5.85.5"
|
||||
dependencies:
|
||||
"@tanstack/query-core": "npm:5.75.5"
|
||||
"@tanstack/query-core": "npm:5.85.5"
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
checksum: 10c0/658e6b36577c531659e19da19ce46005ae36e17517409f4105c4e9181b7ec2ac21bd6d1e2415b8e16004fdd4f7c51b724af1327834b16f1d037edabeaf9b1cbc
|
||||
checksum: 10c0/7518cd624f9fe7c258b460192a73021a1e7fe0e0ea4173de69ca0a69cf60cc812bdb59b13c7f9acdbf45f4f0e82e8efb1e739528cf26e334e4a80606d7c7050d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -7854,13 +7908,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/diff@npm:^7":
|
||||
version: 7.0.2
|
||||
resolution: "@types/diff@npm:7.0.2"
|
||||
checksum: 10c0/ac4de3f982242292e006ace98a9d41363ebc244145939466139828ffa6c476acc15eea2bad39bd7e0868003c497614f6d7e734d4999c4f09d95dfd173d24d723
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/estree-jsx@npm:^1.0.0":
|
||||
version: 1.0.5
|
||||
resolution: "@types/estree-jsx@npm:1.0.5"
|
||||
@ -7912,16 +7959,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/hoist-non-react-statics@npm:^3.3.1":
|
||||
version: 3.3.6
|
||||
resolution: "@types/hoist-non-react-statics@npm:3.3.6"
|
||||
dependencies:
|
||||
"@types/react": "npm:*"
|
||||
hoist-non-react-statics: "npm:^3.3.0"
|
||||
checksum: 10c0/149a4c217d81f21f8a1e152160a59d5b99b6a9aa6d354385d5f5bc02760cbf1e170a8442ba92eb653befff44b0c5bc2234bb77ce33e0d11a65f779e8bab5c321
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/http-cache-semantics@npm:*, @types/http-cache-semantics@npm:^4.0.2":
|
||||
version: 4.0.4
|
||||
resolution: "@types/http-cache-semantics@npm:4.0.4"
|
||||
@ -8112,7 +8149,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:*, @types/react@npm:^19.0.12":
|
||||
"@types/react@npm:^19.0.12":
|
||||
version: 19.1.2
|
||||
resolution: "@types/react@npm:19.1.2"
|
||||
dependencies:
|
||||
@ -8179,13 +8216,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/use-sync-external-store@npm:^0.0.3":
|
||||
version: 0.0.3
|
||||
resolution: "@types/use-sync-external-store@npm:0.0.3"
|
||||
checksum: 10c0/82824c1051ba40a00e3d47964cdf4546a224e95f172e15a9c62aa3f118acee1c7518b627a34f3aa87298a2039f982e8509f92bfcc18bea7c255c189c293ba547
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/use-sync-external-store@npm:^0.0.6":
|
||||
version: 0.0.6
|
||||
resolution: "@types/use-sync-external-store@npm:0.0.6"
|
||||
@ -9125,7 +9155,7 @@ __metadata:
|
||||
"@eslint-react/eslint-plugin": "npm:^1.36.1"
|
||||
"@eslint/js": "npm:^9.22.0"
|
||||
"@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch"
|
||||
"@hello-pangea/dnd": "npm:^16.6.0"
|
||||
"@hello-pangea/dnd": "npm:^18.0.1"
|
||||
"@kangfenmao/keyv-storage": "npm:^0.1.0"
|
||||
"@langchain/community": "npm:^0.3.36"
|
||||
"@langchain/ollama": "npm:^0.2.1"
|
||||
@ -9134,6 +9164,7 @@ __metadata:
|
||||
"@mistralai/mistralai": "npm:^1.7.5"
|
||||
"@modelcontextprotocol/sdk": "npm:^1.17.0"
|
||||
"@mozilla/readability": "npm:^0.6.0"
|
||||
"@napi-rs/system-ocr": "npm:^1.0.2"
|
||||
"@notionhq/client": "npm:^2.2.15"
|
||||
"@openrouter/ai-sdk-provider": "npm:^1.1.2"
|
||||
"@opentelemetry/api": "npm:^1.9.0"
|
||||
@ -9144,10 +9175,10 @@ __metadata:
|
||||
"@opentelemetry/sdk-trace-web": "npm:^2.0.0"
|
||||
"@playwright/test": "npm:^1.52.0"
|
||||
"@reduxjs/toolkit": "npm:^2.2.5"
|
||||
"@shikijs/markdown-it": "npm:^3.9.1"
|
||||
"@shikijs/markdown-it": "npm:^3.12.0"
|
||||
"@strongtz/win32-arm64-msvc": "npm:^0.4.7"
|
||||
"@swc/plugin-styled-components": "npm:^8.0.4"
|
||||
"@tanstack/react-query": "npm:^5.27.0"
|
||||
"@tanstack/react-query": "npm:^5.85.5"
|
||||
"@tanstack/react-virtual": "npm:^3.13.12"
|
||||
"@testing-library/dom": "npm:^10.4.0"
|
||||
"@testing-library/jest-dom": "npm:^6.6.3"
|
||||
@ -9155,7 +9186,6 @@ __metadata:
|
||||
"@testing-library/user-event": "npm:^14.6.1"
|
||||
"@tryfabric/martian": "npm:^1.2.4"
|
||||
"@types/cli-progress": "npm:^3"
|
||||
"@types/diff": "npm:^7"
|
||||
"@types/fs-extra": "npm:^11"
|
||||
"@types/lodash": "npm:^4.17.5"
|
||||
"@types/markdown-it": "npm:^14"
|
||||
@ -9193,7 +9223,7 @@ __metadata:
|
||||
dayjs: "npm:^1.11.11"
|
||||
dexie: "npm:^4.0.8"
|
||||
dexie-react-hooks: "npm:^1.1.7"
|
||||
diff: "npm:^7.0.0"
|
||||
diff: "npm:^8.0.2"
|
||||
docx: "npm:^9.0.2"
|
||||
dotenv-cli: "npm:^7.4.2"
|
||||
electron: "npm:37.3.1"
|
||||
@ -9225,14 +9255,14 @@ __metadata:
|
||||
jaison: "npm:^2.0.2"
|
||||
jest-styled-components: "npm:^7.2.0"
|
||||
jsdom: "npm:26.1.0"
|
||||
linguist-languages: "npm:^8.0.0"
|
||||
linguist-languages: "npm:^8.1.0"
|
||||
lint-staged: "npm:^15.5.0"
|
||||
lodash: "npm:^4.17.21"
|
||||
lru-cache: "npm:^11.1.0"
|
||||
lucide-react: "npm:^0.525.0"
|
||||
macos-release: "npm:^3.4.0"
|
||||
markdown-it: "npm:^14.1.0"
|
||||
mermaid: "npm:^11.9.0"
|
||||
mermaid: "npm:^11.10.1"
|
||||
mime: "npm:^4.0.4"
|
||||
motion: "npm:^12.10.5"
|
||||
node-stream-zip: "npm:^1.15.0"
|
||||
@ -9277,7 +9307,7 @@ __metadata:
|
||||
sass: "npm:^1.88.0"
|
||||
selection-hook: "npm:^1.0.11"
|
||||
sharp: "npm:^0.34.3"
|
||||
shiki: "npm:^3.9.1"
|
||||
shiki: "npm:^3.12.0"
|
||||
strict-url-sanitise: "npm:^0.0.1"
|
||||
string-width: "npm:^7.2.0"
|
||||
styled-components: "npm:^6.1.11"
|
||||
@ -12030,13 +12060,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"diff@npm:^7.0.0":
|
||||
version: 7.0.0
|
||||
resolution: "diff@npm:7.0.0"
|
||||
checksum: 10c0/251fd15f85ffdf814cfc35a728d526b8d2ad3de338dcbd011ac6e57c461417090766b28995f8ff733135b5fbc3699c392db1d5e27711ac4e00244768cd1d577b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"diff@npm:^8.0.2":
|
||||
version: 8.0.2
|
||||
resolution: "diff@npm:8.0.2"
|
||||
@ -14562,15 +14585,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2":
|
||||
version: 3.3.2
|
||||
resolution: "hoist-non-react-statics@npm:3.3.2"
|
||||
dependencies:
|
||||
react-is: "npm:^16.7.0"
|
||||
checksum: 10c0/fe0889169e845d738b59b64badf5e55fa3cf20454f9203d1eb088df322d49d4318df774828e789898dcb280e8a5521bb59b3203385662ca5e9218a6ca5820e74
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hookable@npm:^5.5.3":
|
||||
version: 5.5.3
|
||||
resolution: "hookable@npm:5.5.3"
|
||||
@ -16003,10 +16017,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"linguist-languages@npm:^8.0.0":
|
||||
version: 8.0.0
|
||||
resolution: "linguist-languages@npm:8.0.0"
|
||||
checksum: 10c0/eaae46254247b9aa5b287ac98e062e7fe859314328ce305e34e152bc7bb172d69633999320cb47dc2a710388179712a76bb1ddd6e39e249af2684a4f0a66256c
|
||||
"linguist-languages@npm:^8.1.0":
|
||||
version: 8.1.0
|
||||
resolution: "linguist-languages@npm:8.1.0"
|
||||
checksum: 10c0/ba0a03efb6ac9e645da51e02df5c1dd54ecb8b1218347f70b2441fa239bbba65feb54eb084d366636a98419eef3f2156a36bb3a6161465423ddd4e0bea3b8ea0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -16839,13 +16853,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"memoize-one@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "memoize-one@npm:6.0.0"
|
||||
checksum: 10c0/45c88e064fd715166619af72e8cf8a7a17224d6edf61f7a8633d740ed8c8c0558a4373876c9b8ffc5518c2b65a960266adf403cc215cb1e90f7e262b58991f54
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"merge-descriptors@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "merge-descriptors@npm:2.0.0"
|
||||
@ -16867,9 +16874,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mermaid@npm:^11.9.0":
|
||||
version: 11.9.0
|
||||
resolution: "mermaid@npm:11.9.0"
|
||||
"mermaid@npm:^11.10.1":
|
||||
version: 11.10.1
|
||||
resolution: "mermaid@npm:11.10.1"
|
||||
dependencies:
|
||||
"@braintree/sanitize-url": "npm:^7.0.4"
|
||||
"@iconify/utils": "npm:^2.1.33"
|
||||
@ -16891,7 +16898,7 @@ __metadata:
|
||||
stylis: "npm:^4.3.6"
|
||||
ts-dedent: "npm:^2.2.0"
|
||||
uuid: "npm:^11.1.0"
|
||||
checksum: 10c0/f3420d0fd8919b31e36354cbf0ddd26398898c960e0bcb0e52aceae657245fcf1e5fe3e28651bff83c9b1fb8b6d3e07fc8b26d111ef3159fcf780d53ce40a437
|
||||
checksum: 10c0/f20820a3b2b2a79b7ab61b6b31b833c6f2d57e047d8051dbd71db645ee6fda6b86ef2e042e04dc372d1af5ba4cd97c91493b2c1b1702713fa2bae40ddaff9b26
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -19931,7 +19938,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-is@npm:^16.13.1, react-is@npm:^16.7.0":
|
||||
"react-is@npm:^16.13.1":
|
||||
version: 16.13.1
|
||||
resolution: "react-is@npm:16.13.1"
|
||||
checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1
|
||||
@ -19945,7 +19952,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-is@npm:^18.0.0, react-is@npm:^18.2.0":
|
||||
"react-is@npm:^18.2.0":
|
||||
version: 18.3.1
|
||||
resolution: "react-is@npm:18.3.1"
|
||||
checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072
|
||||
@ -19996,39 +20003,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-redux@npm:^8.1.3":
|
||||
version: 8.1.3
|
||||
resolution: "react-redux@npm:8.1.3"
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.12.1"
|
||||
"@types/hoist-non-react-statics": "npm:^3.3.1"
|
||||
"@types/use-sync-external-store": "npm:^0.0.3"
|
||||
hoist-non-react-statics: "npm:^3.3.2"
|
||||
react-is: "npm:^18.0.0"
|
||||
use-sync-external-store: "npm:^1.0.0"
|
||||
peerDependencies:
|
||||
"@types/react": ^16.8 || ^17.0 || ^18.0
|
||||
"@types/react-dom": ^16.8 || ^17.0 || ^18.0
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
react-native: ">=0.59"
|
||||
redux: ^4 || ^5.0.0-beta.0
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
"@types/react-dom":
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
checksum: 10c0/64c8be2765568dc66a3c442a41dd0ed74fe048d5ceb7a4fe72e5bac3d3687996a7115f57b5156af7406521087065a0e60f9194318c8ca99c55e9ce48558980ce
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-redux@npm:^9.1.2":
|
||||
"react-redux@npm:^9.1.2, react-redux@npm:^9.2.0":
|
||||
version: 9.2.0
|
||||
resolution: "react-redux@npm:9.2.0"
|
||||
dependencies:
|
||||
@ -20219,15 +20194,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"redux@npm:^4.2.1":
|
||||
version: 4.2.1
|
||||
resolution: "redux@npm:4.2.1"
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.9.2"
|
||||
checksum: 10c0/136d98b3d5dbed1cd6279c8c18a6a74c416db98b8a432a46836bdd668475de6279a2d4fd9d1363f63904e00f0678a8a3e7fa532c897163340baf1e71bb42c742
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"redux@npm:^5.0.1":
|
||||
version: 5.0.1
|
||||
resolution: "redux@npm:5.0.1"
|
||||
@ -21190,19 +21156,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"shiki@npm:3.9.1, shiki@npm:^3.9.1":
|
||||
version: 3.9.1
|
||||
resolution: "shiki@npm:3.9.1"
|
||||
"shiki@npm:3.12.0, shiki@npm:^3.12.0":
|
||||
version: 3.12.0
|
||||
resolution: "shiki@npm:3.12.0"
|
||||
dependencies:
|
||||
"@shikijs/core": "npm:3.9.1"
|
||||
"@shikijs/engine-javascript": "npm:3.9.1"
|
||||
"@shikijs/engine-oniguruma": "npm:3.9.1"
|
||||
"@shikijs/langs": "npm:3.9.1"
|
||||
"@shikijs/themes": "npm:3.9.1"
|
||||
"@shikijs/types": "npm:3.9.1"
|
||||
"@shikijs/core": "npm:3.12.0"
|
||||
"@shikijs/engine-javascript": "npm:3.12.0"
|
||||
"@shikijs/engine-oniguruma": "npm:3.12.0"
|
||||
"@shikijs/langs": "npm:3.12.0"
|
||||
"@shikijs/themes": "npm:3.12.0"
|
||||
"@shikijs/types": "npm:3.12.0"
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
"@types/hast": "npm:^3.0.4"
|
||||
checksum: 10c0/383ca4b91b0ade1df7ce8889c4abeb9bfabead53a808f11de749e44f8400b3967d8bad7aad99a8ecf7991a2e1d1c42a71b73154d12baca6deeb979b9929376cb
|
||||
checksum: 10c0/70d676b54cb688cafec1eb33d9592c2aee12b5a212b89c0c783ad032d5eeccf1c651af96e16b9f8355811193d50b42ad62aad5925c8cca92f17164f3e1e9e8c9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -22904,16 +22870,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"use-memo-one@npm:^1.1.3":
|
||||
version: 1.1.3
|
||||
resolution: "use-memo-one@npm:1.1.3"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/3d596e65a6b47b2f1818061599738e00daad1f9a9bb4e5ce1f014b20a35b297e50fe4bf1d8c1699ab43ea97f01f84649a736c15ceff96de83bfa696925f6cc6b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"use-sync-external-store@npm:^1.0.0, use-sync-external-store@npm:^1.2.2, use-sync-external-store@npm:^1.4.0":
|
||||
"use-sync-external-store@npm:^1.2.2, use-sync-external-store@npm:^1.4.0":
|
||||
version: 1.5.0
|
||||
resolution: "use-sync-external-store@npm:1.5.0"
|
||||
peerDependencies:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user