diff --git a/electron-builder.yml b/electron-builder.yml index e51f1917d0..909181956c 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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/** diff --git a/package.json b/package.json index 6f6062ef01..49ed7eab47 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "dependencies": { "@libsql/client": "0.14.0", "@libsql/win32-x64-msvc": "^0.4.7", + "@napi-rs/system-ocr": "^1.0.2", "@strongtz/win32-arm64-msvc": "^0.4.7", "graceful-fs": "^4.2.11", "jsdom": "26.1.0", @@ -123,7 +124,7 @@ "@eslint-react/eslint-plugin": "^1.36.1", "@eslint/js": "^9.22.0", "@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch", - "@hello-pangea/dnd": "^16.6.0", + "@hello-pangea/dnd": "^18.0.1", "@kangfenmao/keyv-storage": "^0.1.0", "@langchain/community": "^0.3.36", "@langchain/ollama": "^0.2.1", @@ -140,9 +141,9 @@ "@opentelemetry/sdk-trace-web": "^2.0.0", "@playwright/test": "^1.52.0", "@reduxjs/toolkit": "^2.2.5", - "@shikijs/markdown-it": "^3.9.1", + "@shikijs/markdown-it": "^3.12.0", "@swc/plugin-styled-components": "^8.0.4", - "@tanstack/react-query": "^5.27.0", + "@tanstack/react-query": "^5.85.5", "@tanstack/react-virtual": "^3.13.12", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -150,7 +151,6 @@ "@testing-library/user-event": "^14.6.1", "@tryfabric/martian": "^1.2.4", "@types/cli-progress": "^3", - "@types/diff": "^7", "@types/fs-extra": "^11", "@types/lodash": "^4.17.5", "@types/markdown-it": "^14", @@ -188,7 +188,7 @@ "dayjs": "^1.11.11", "dexie": "^4.0.8", "dexie-react-hooks": "^1.1.7", - "diff": "^7.0.0", + "diff": "^8.0.2", "docx": "^9.0.2", "dotenv-cli": "^7.4.2", "electron": "37.3.1", @@ -218,14 +218,14 @@ "isbinaryfile": "5.0.4", "jaison": "^2.0.2", "jest-styled-components": "^7.2.0", - "linguist-languages": "^8.0.0", + "linguist-languages": "^8.1.0", "lint-staged": "^15.5.0", "lodash": "^4.17.21", "lru-cache": "^11.1.0", "lucide-react": "^0.525.0", "macos-release": "^3.4.0", "markdown-it": "^14.1.0", - "mermaid": "^11.9.0", + "mermaid": "^11.10.1", "mime": "^4.0.4", "motion": "^12.10.5", "notion-helper": "^1.3.22", @@ -265,7 +265,7 @@ "remove-markdown": "^0.6.2", "rollup-plugin-visualizer": "^5.12.0", "sass": "^1.88.0", - "shiki": "^3.9.1", + "shiki": "^3.12.0", "strict-url-sanitise": "^0.0.1", "string-width": "^7.2.0", "styled-components": "^6.1.11", diff --git a/packages/shared/config/languages.ts b/packages/shared/config/languages.ts index 95b8cab587..42a733bc4a 100644 --- a/packages/shared/config/languages.ts +++ b/packages/shared/config/languages.ts @@ -2020,6 +2020,10 @@ export const languages: Record = { 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 = { }, SWIG: { type: 'programming', - extensions: ['.i'] + extensions: ['.i', '.swg', '.swig'] }, SystemVerilog: { type: 'programming', diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts index 6cc8a41b05..256b4dcbd6 100644 --- a/src/main/services/CodeToolsService.ts +++ b/src/main/services/CodeToolsService.ts @@ -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' diff --git a/src/main/services/ocr/OcrService.ts b/src/main/services/ocr/OcrService.ts index 6ac8c311e3..0d7383a24a 100644 --- a/src/main/services/ocr/OcrService.ts +++ b/src/main/services/ocr/OcrService.ts @@ -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)) diff --git a/src/main/services/ocr/builtin/OcrBaseService.ts b/src/main/services/ocr/builtin/OcrBaseService.ts new file mode 100644 index 0000000000..9c36e79c3a --- /dev/null +++ b/src/main/services/ocr/builtin/OcrBaseService.ts @@ -0,0 +1,5 @@ +import { OcrHandler } from '@types' + +export abstract class OcrBaseService { + abstract ocr: OcrHandler +} diff --git a/src/main/services/ocr/builtin/SystemOcrService.ts b/src/main/services/ocr/builtin/SystemOcrService.ts new file mode 100644 index 0000000000..cda52bfec6 --- /dev/null +++ b/src/main/services/ocr/builtin/SystemOcrService.ts @@ -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 { + 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 => { + 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() diff --git a/src/main/services/ocr/builtin/TesseractService.ts b/src/main/services/ocr/builtin/TesseractService.ts new file mode 100644 index 0000000000..9fd7bbcf01 --- /dev/null +++ b/src/main/services/ocr/builtin/TesseractService.ts @@ -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 { + 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((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 { + 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 => { + if (!isImageFileMetadata(file)) { + throw new Error('Only image files are supported currently') + } + return this.imageOcr(file, options) + } + + private async _getLangPath(): Promise { + const country = await getIpCountry() + return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : '' + } + + private async _getCacheDir(): Promise { + 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 { + if (this.worker) { + await this.worker.terminate() + this.worker = null + } + } +} + +export const tesseractService = new TesseractService() diff --git a/src/main/services/ocr/tesseract/TesseractService.ts b/src/main/services/ocr/tesseract/TesseractService.ts deleted file mode 100644 index d2ba6d2ed8..0000000000 --- a/src/main/services/ocr/tesseract/TesseractService.ts +++ /dev/null @@ -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 { - 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 { - 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 { - if (!isImageFile(file)) { - throw new Error('Only image files are supported currently') - } - return this.imageOcr(file) - } - - private async _getLangPath(): Promise { - const country = await getIpCountry() - return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : TesseractLangsDownloadUrl.GLOBAL - } - - private async _getCacheDir(): Promise { - 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 { - if (this.worker) { - await this.worker.terminate() - this.worker = null - } - } -} - -export const tesseractService = new TesseractService() diff --git a/src/main/utils/ocr.ts b/src/main/utils/ocr.ts index ca63e82f07..446fbe63d6 100644 --- a/src/main/utils/ocr.ts +++ b/src/main/utils/ocr.ts @@ -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 => { + 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 => { const buffer = await readFile(file.path) - return await preprocessImage(buffer) + return preprocessImage(buffer) } diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts index feebf487e0..5649b86984 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts @@ -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 = {} 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')) } } diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx index acb4a9c4f1..13d13c55a9 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx @@ -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 = ({ 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 = ({ 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' }) } diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index 216e247701..8cdf4e4d45 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -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 = ({ open, title, ht const { t } = useTranslation() const [viewMode, setViewMode] = useState('split') const [isFullscreen, setIsFullscreen] = useState(false) + const [saved, setSaved] = useTemporaryValue(false, 2000) const codeEditorRef = useRef(null) + const previewFrameRef = useRef(null) // Prevent body scroll when fullscreen useEffect(() => { @@ -38,8 +44,32 @@ const HtmlArtifactsPopup: React.FC = ({ 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 = () => ( setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}> @@ -47,7 +77,7 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht - + e.stopPropagation()}> = ({ open, title, ht - + e.stopPropagation()}> + , + onClick: () => handleCapture('file') + }, + { + label: t('html_artifacts.capture.to_clipboard'), + key: 'capture_to_clipboard', + icon: , + onClick: () => handleCapture('clipboard') + } + ] + }}> + +