Merge branch 'main' of github.com:CherryHQ/cherry-studio into feat/aisdk-package

This commit is contained in:
icarus 2025-08-29 19:34:55 +08:00
commit 9bde8b3cae
79 changed files with 2377 additions and 996 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { OcrHandler } from '@types'
export abstract class OcrBaseService {
abstract ocr: OcrHandler
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) || ''
}
// 默认使用日期格式命名

View File

@ -1,7 +1,7 @@
import { CSSProperties, SVGProps } from 'react'
interface BaseFileIconProps extends SVGProps<SVGSVGElement> {
size?: string
size?: string | number
text?: string
}

View File

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

View 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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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": "ソース言語が設定された言語と異なります",

View File

@ -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": "Исходный язык отличается от настроенного",

View File

@ -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": "源语言与设置的语言不同",

View File

@ -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": "源語言與設定的語言不同",

View File

@ -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": "Η γλώσσα πηγής διαφέρει από την οριζόμενη γλώσσα",

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export { default as ProviderList } from './ProviderList'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,7 +64,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 137,
version: 138,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},

View File

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

View File

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

View File

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

View File

@ -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应当能够唯一确认一种语言

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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