diff --git a/.gitignore b/.gitignore index b74d3d5821..39b5630926 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ coverage .vitest-cache vitest.config.*.timestamp-* +# TypeScript incremental build +.tsbuildinfo + # playwright playwright-report test-results diff --git a/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch b/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch new file mode 100644 index 0000000000..5c64db053b --- /dev/null +++ b/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch @@ -0,0 +1,30 @@ +diff --git a/index.js b/index.js +index dc071739e79876dff88e1be06a9168e294222d13..b9df7525c62bdf777e89e732e1b0c81f84d872f2 100644 +--- a/index.js ++++ b/index.js +@@ -380,7 +380,7 @@ if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { + } + } + +-if (!nativeBinding) { ++if (!nativeBinding && process.platform !== 'linux') { + if (loadErrors.length > 0) { + throw new Error( + `Cannot find native binding. ` + +@@ -392,6 +392,13 @@ if (!nativeBinding) { + throw new Error(`Failed to load native binding`) + } + +-module.exports = nativeBinding +-module.exports.OcrAccuracy = nativeBinding.OcrAccuracy +-module.exports.recognize = nativeBinding.recognize ++if (process.platform === 'linux') { ++ module.exports = {OcrAccuracy: { ++ Fast: 0, ++ Accurate: 1 ++ }, recognize: () => Promise.resolve({text: '', confidence: 1.0})} ++}else{ ++ module.exports = nativeBinding ++ module.exports.OcrAccuracy = nativeBinding.OcrAccuracy ++ module.exports.recognize = nativeBinding.recognize ++} diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 25719dc11f..ec2d008885 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -4,6 +4,8 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import { resolve } from 'path' import { visualizer } from 'rollup-plugin-visualizer' +import pkg from './package.json' assert { type: 'json' } + const visualizerPlugin = (type: 'renderer' | 'main') => { return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : [] } @@ -27,7 +29,7 @@ export default defineConfig({ }, build: { rollupOptions: { - external: ['@libsql/client', 'bufferutil', 'utf-8-validate'], + external: ['bufferutil', 'utf-8-validate', 'electron', ...Object.keys(pkg.dependencies)], output: { manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包 inlineDynamicImports: true // 内联所有动态导入,这是关键配置 diff --git a/package.json b/package.json index 9a8d17b560..72bf924ceb 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "generate:icons": "electron-icon-builder --input=./build/logo.png --output=build", "analyze:renderer": "VISUALIZER_RENDERER=true yarn build", "analyze:main": "VISUALIZER_MAIN=true yarn build", - "typecheck": "npm run typecheck:node && npm run typecheck:web", + "typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "check:i18n": "tsx scripts/check-i18n.ts", @@ -73,7 +73,7 @@ "dependencies": { "@libsql/client": "0.14.0", "@libsql/win32-x64-msvc": "^0.4.7", - "@napi-rs/system-ocr": "^1.0.2", + "@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch", "@strongtz/win32-arm64-msvc": "^0.4.7", "graceful-fs": "^4.2.11", "jsdom": "26.1.0", @@ -200,6 +200,7 @@ "cli-progress": "^3.12.0", "code-inspector-plugin": "^0.20.14", "color": "^5.0.0", + "concurrently": "^9.2.1", "country-flag-emoji-polyfill": "0.1.8", "dayjs": "^1.11.11", "dexie": "^4.0.8", diff --git a/resources/data/agents-en.json b/resources/data/agents-en.json index c8c4bab393..b594f32a44 100644 --- a/resources/data/agents-en.json +++ b/resources/data/agents-en.json @@ -2089,7 +2089,7 @@ "Design", "Education" ], - "prompt": "I want you to act as a Graphviz DOT generator, an expert to create meaningful diagrams. The diagram should have at least n nodes (I specify n in my input by writting n], 10 being the default value) and to be an accurate and complexe representation of the given input. Each node is indexed by a number to reduce the size of the output, should not include any styling, and with layout=neato, overlap=false, node shape=rectangle] as parameters. The code should be valid, bugless and returned on a single line, without any explanation. Provide a clear and organized diagram, the relationships between the nodes have to make sense for an expert of that input. My first diagram is: \"The water cycle 8]\".\n\n", + "prompt": "I want you to act as a Graphviz DOT generator, an expert to create meaningful diagrams. The diagram should have at least n nodes (I specify n in my input by writing n], 10 being the default value) and to be an accurate and complex representation of the given input. Each node is indexed by a number to reduce the size of the output, should not include any styling, and with layout=neato, overlap=false, node shape=rectangle] as parameters. The code should be valid, bugless and returned on a single line, without any explanation. Provide a clear and organized diagram, the relationships between the nodes have to make sense for an expert of that input. My first diagram is: \"The water cycle 8]\".\n\n", "description": "Generate meaningful charts." }, { @@ -2148,7 +2148,7 @@ "Career", "Business" ], - "prompt": "Please acknowledge my following request. Please respond to me as a product manager. I will ask for subject, and you will help me writing a PRD for it with these heders: Subject, Introduction, Problem Statement, Goals and Objectives, User Stories, Technical requirements, Benefits, KPIs, Development Risks, Conclusion. Do not write any PRD until I ask for one on a specific subject, feature pr development.\n\n", + "prompt": "Please acknowledge my following request. Please respond to me as a product manager. I will ask for subject, and you will help me writing a PRD for it with these headers: Subject, Introduction, Problem Statement, Goals and Objectives, User Stories, Technical requirements, Benefits, KPIs, Development Risks, Conclusion. Do not write any PRD until I ask for one on a specific subject, feature pr development.\n\n", "description": "Help draft the Product Requirements Document." }, { @@ -2159,7 +2159,7 @@ "Entertainment", "General" ], - "prompt": "I want you to act as a drunk person. You will only answer like a very drunk person texting and nothing else. Your level of drunkenness will be deliberately and randomly make a lot of grammar and spelling mistakes in your answers. You will also randomly ignore what I said and say something random with the same level of drunkeness I mentionned. Do not write explanations on replies. My first sentence is \"how are you?", + "prompt": "I want you to act as a drunk person. You will only answer like a very drunk person texting and nothing else. Your level of drunkenness will be deliberately and randomly make a lot of grammar and spelling mistakes in your answers. You will also randomly ignore what I said and say something random with the same level of drunkenness I mentioned. Do not write explanations on replies. My first sentence is \"how are you?", "description": "Mimic the speech pattern of a drunk person." }, { @@ -3517,7 +3517,7 @@ "Tools", "Copywriting" ], - "prompt": "I want you to act as a scientific manuscript matcher. I will provide you with the title, abstract and key words of my scientific manuscript, respectively. Your task is analyzing my title, abstract and key words synthetically to find the most related, reputable journals for potential publication of my research based on an analysis of tens of millions of citation connections in database, such as Web of Science, Pubmed, Scopus, ScienceDirect and so on. You only need to provide me with the 15 most suitable journals. Your reply should include the name of journal, the cooresponding match score (The full score is ten). I want you to reply in text-based excel sheet and sort by matching scores in reverse order.\nMy title is \"XXX\" My abstract is \"XXX\" My key words are \"XXX\"\n\n", + "prompt": "I want you to act as a scientific manuscript matcher. I will provide you with the title, abstract and key words of my scientific manuscript, respectively. Your task is analyzing my title, abstract and key words synthetically to find the most related, reputable journals for potential publication of my research based on an analysis of tens of millions of citation connections in database, such as Web of Science, Pubmed, Scopus, ScienceDirect and so on. You only need to provide me with the 15 most suitable journals. Your reply should include the name of journal, the corresponding match score (The full score is ten). I want you to reply in text-based excel sheet and sort by matching scores in reverse order.\nMy title is \"XXX\" My abstract is \"XXX\" My key words are \"XXX\"\n\n", "description": "" }, { diff --git a/scripts/before-pack.js b/scripts/before-pack.js index f0d4bdb096..59c0a39171 100644 --- a/scripts/before-pack.js +++ b/scripts/before-pack.js @@ -7,15 +7,12 @@ const allArm64 = { '@img/sharp-darwin-arm64': '0.34.3', '@img/sharp-win32-arm64': '0.34.3', '@img/sharp-linux-arm64': '0.34.3', - '@img/sharp-linuxmusl-arm64': '0.34.3', '@img/sharp-libvips-darwin-arm64': '1.2.0', '@img/sharp-libvips-linux-arm64': '1.2.0', - '@img/sharp-libvips-linuxmusl-arm64': '1.2.0', '@libsql/darwin-arm64': '0.4.7', '@libsql/linux-arm64-gnu': '0.4.7', - '@libsql/linux-arm64-musl': '0.4.7', '@strongtz/win32-arm64-msvc': '0.4.7', '@napi-rs/system-ocr-darwin-arm64': '1.0.2', @@ -25,16 +22,13 @@ const allArm64 = { const allX64 = { '@img/sharp-darwin-x64': '0.34.3', '@img/sharp-linux-x64': '0.34.3', - '@img/sharp-linuxmusl-x64': '0.34.3', '@img/sharp-win32-x64': '0.34.3', '@img/sharp-libvips-darwin-x64': '1.2.0', '@img/sharp-libvips-linux-x64': '1.2.0', - '@img/sharp-libvips-linuxmusl-x64': '1.2.0', '@libsql/darwin-x64': '0.4.7', '@libsql/linux-x64-gnu': '0.4.7', - '@libsql/linux-x64-musl': '0.4.7', '@libsql/win32-x64-msvc': '0.4.7', '@napi-rs/system-ocr-darwin-x64': '1.0.2', diff --git a/src/main/services/ObsidianVaultService.ts b/src/main/services/ObsidianVaultService.ts index 93c5421eef..d90def8524 100644 --- a/src/main/services/ObsidianVaultService.ts +++ b/src/main/services/ObsidianVaultService.ts @@ -32,7 +32,8 @@ class ObsidianVaultService { ) } else { // Linux - this.obsidianConfigPath = path.join(app.getPath('home'), '.config', 'obsidian', 'obsidian.json') + this.obsidianConfigPath = this.resolveLinuxObsidianConfigPath() + logger.debug(`Resolved Obsidian config path (linux): ${this.obsidianConfigPath}`) } } @@ -164,6 +165,57 @@ class ObsidianVaultService { return [] } } + + /** + * 在 Linux 下解析 Obsidian 配置文件路径,兼容多种安装方式。 + * 优先返回第一个存在的路径;若均不存在,则返回 XDG 默认路径。 + */ + private resolveLinuxObsidianConfigPath(): string { + const home = app.getPath('home') + const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(home, '.config') + + // 常见目录名与文件名大小写差异做兼容 + const configDirs = ['obsidian', 'Obsidian'] + const fileNames = ['obsidian.json', 'Obsidian.json'] + + const candidates: string[] = [] + + // 1) AppImage/DEB(XDG 标准路径) + for (const dir of configDirs) { + for (const file of fileNames) { + candidates.push(path.join(xdgConfigHome, dir, file)) + } + } + + // 2) Snap 安装: + // - 常见:~/snap/obsidian/current/.config/obsidian/obsidian.json + // - 兼容:~/snap/obsidian/common/.config/obsidian/obsidian.json + for (const dir of configDirs) { + for (const file of fileNames) { + candidates.push(path.join(home, 'snap', 'obsidian', 'current', '.config', dir, file)) + candidates.push(path.join(home, 'snap', 'obsidian', 'common', '.config', dir, file)) + } + } + + // 3) Flatpak 安装:~/.var/app/md.obsidian.Obsidian/config/obsidian/obsidian.json + for (const dir of configDirs) { + for (const file of fileNames) { + candidates.push(path.join(home, '.var', 'app', 'md.obsidian.Obsidian', 'config', dir, file)) + } + } + + const existing = candidates.find((p) => { + try { + return fs.existsSync(p) + } catch { + return false + } + }) + + if (existing) return existing + + return path.join(xdgConfigHome, 'obsidian', 'obsidian.json') + } } export default ObsidianVaultService diff --git a/src/main/services/ocr/OcrService.ts b/src/main/services/ocr/OcrService.ts index 20ea201226..dfd796346f 100644 --- a/src/main/services/ocr/OcrService.ts +++ b/src/main/services/ocr/OcrService.ts @@ -2,6 +2,7 @@ import { loggerService } from '@logger' import { isLinux } from '@main/constant' import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types' +import { systemOcrService } from './builtin/SystemOcrService' import { tesseractService } from './builtin/TesseractService' const logger = loggerService.withContext('OcrService') @@ -34,7 +35,4 @@ export const ocrService = new OcrService() // Register built-in providers ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService)) -if (!isLinux) { - const { systemOcrService } = require('./builtin/SystemOcrService') - ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService)) -} +!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService)) diff --git a/src/main/services/ocr/builtin/SystemOcrService.ts b/src/main/services/ocr/builtin/SystemOcrService.ts index f6fcfe32a7..34a8bb8ce9 100644 --- a/src/main/services/ocr/builtin/SystemOcrService.ts +++ b/src/main/services/ocr/builtin/SystemOcrService.ts @@ -1,5 +1,6 @@ import { isLinux, isWin } from '@main/constant' import { loadOcrImage } from '@main/utils/ocr' +import { OcrAccuracy, recognize } from '@napi-rs/system-ocr' import { ImageFileMetadata, isImageFileMetadata as isImageFileMetadata, @@ -20,8 +21,6 @@ export class SystemOcrService extends OcrBaseService { if (isLinux) { return { text: '' } } - - const { OcrAccuracy, recognize } = require('@napi-rs/system-ocr') const buffer = await loadOcrImage(file) const langs = isWin ? options?.langs : undefined const result = await recognize(buffer, OcrAccuracy.Accurate, langs) diff --git a/src/main/utils/ocr.ts b/src/main/utils/ocr.ts index d94fb002d1..446fbe63d6 100644 --- a/src/main/utils/ocr.ts +++ b/src/main/utils/ocr.ts @@ -1,8 +1,8 @@ import { ImageFileMetadata } from '@types' import { readFile } from 'fs/promises' +import sharp from 'sharp' const preprocessImage = async (buffer: Buffer): Promise => { - const sharp = require('sharp') return sharp(buffer) .grayscale() // 转为灰度 .normalize() diff --git a/src/renderer/src/assets/images/banner.png b/src/renderer/src/assets/images/banner.png new file mode 100644 index 0000000000..e29198cf82 Binary files /dev/null and b/src/renderer/src/assets/images/banner.png differ diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx index a9e038869a..bd4dd753a6 100644 --- a/src/renderer/src/components/CodeBlockView/view.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -262,12 +262,13 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave ) : ( - {children} - + maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`} + /> ), [children, codeEditorEnabled, handleHeightChange, language, onSave, shouldExpand, shouldWrap] ) diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx index ccd2496209..85d874b766 100644 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -48,8 +48,6 @@ export interface CodeEditorProps { maxHeight?: string /** Minimum editor height. */ minHeight?: string - /** Font size that overrides the app setting. */ - fontSize?: string /** Editor options that extend BasicSetupOptions. */ options?: { /** @@ -70,6 +68,8 @@ export interface CodeEditorProps { } & BasicSetupOptions /** Additional extensions for CodeMirror. */ extensions?: Extension[] + /** Font size that overrides the app setting. */ + fontSize?: number /** Style overrides for the editor, passed directly to CodeMirror's style property. */ style?: React.CSSProperties /** CSS class name appended to the default `code-editor` class. */ @@ -108,9 +108,9 @@ const CodeEditor = ({ height, maxHeight, minHeight, - fontSize, options, extensions, + fontSize: customFontSize, style, className, editable = true, @@ -131,7 +131,7 @@ const CodeEditor = ({ const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap]) // 合并 codeEditor 和 options 的 basicSetup,options 优先 - const customBasicSetup = useMemo(() => { + const basicSetup = useMemo(() => { return { lineNumbers: _lineNumbers, ...(codeEditor as BasicSetupOptions), @@ -139,7 +139,7 @@ const CodeEditor = ({ } }, [codeEditor, _lineNumbers, options]) - const customFontSize = useMemo(() => fontSize ?? `${_fontSize - 1}px`, [fontSize, _fontSize]) + const fontSize = useMemo(() => customFontSize ?? _fontSize - 1, [customFontSize, _fontSize]) const { activeCmTheme } = useCodeStyle() const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? '')) @@ -224,10 +224,10 @@ const CodeEditor = ({ foldKeymap: enableKeymap, completionKeymap: enableKeymap, lintKeymap: enableKeymap, - ...customBasicSetup // override basicSetup + ...basicSetup // override basicSetup }} style={{ - fontSize: customFontSize, + fontSize, marginTop: 0, borderRadius: 'inherit', ...style diff --git a/src/renderer/src/components/CodeViewer.tsx b/src/renderer/src/components/CodeViewer.tsx index 7c634716b1..1c102acf29 100644 --- a/src/renderer/src/components/CodeViewer.tsx +++ b/src/renderer/src/components/CodeViewer.tsx @@ -1,5 +1,4 @@ import { usePreference } from '@data/hooks/usePreference' -import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight' import { uuid } from '@renderer/utils' @@ -11,13 +10,49 @@ import { ThemedToken } from 'shiki/core' import styled from 'styled-components' interface CodeViewerProps { + /** Code string value. */ + value: string + /** + * Code language string. + * - Case-insensitive. + * - Supports common names: javascript, json, python, etc. + * - Supports shiki aliases: c#/csharp, objective-c++/obj-c++/objc++, etc. + */ language: string - children: string - expanded?: boolean - wrapped?: boolean + /** Fired when the editor height changes. */ onHeightChange?: (scrollHeight: number) => void - className?: string + /** + * Height of the scroll container. + * Only works when expanded is false. + */ height?: string | number + /** + * Maximum height of the scroll container. + * Only works when expanded is false. + */ + maxHeight?: string | number + /** Viewer options. */ + options?: { + /** + * Whether to show line numbers. + */ + lineNumbers?: boolean + } + /** Font size that overrides the app setting. */ + fontSize?: number + /** CSS class name appended to the default `code-viewer` class. */ + className?: string + /** + * Whether the editor is expanded. + * If true, the height and maxHeight props are ignored. + * @default true + */ + expanded?: boolean + /** + * Whether the code lines are wrapped. + * @default true + */ + wrapped?: boolean } /** @@ -26,20 +61,34 @@ interface CodeViewerProps { * - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应 * - 并发安全 */ -const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, className, height }: CodeViewerProps) => { - const [codeShowLineNumbers] = usePreference('chat.code.show_line_numbers') - const [fontSize] = usePreference('chat.message.font_size') +const CodeViewer = ({ + value, + language, + height, + maxHeight, + onHeightChange, + options, + fontSize: customFontSize, + className, + expanded = true, + wrapped = true +}: CodeViewerProps) => { + const [_lineNumbers] = usePreference('chat.code.show_line_numbers') + const [_fontSize] = usePreference('chat.message.font_size') const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle() const shikiThemeRef = useRef(null) const scrollerRef = useRef(null) const callerId = useRef(`${Date.now()}-${uuid()}`).current - const rawLines = useMemo(() => (typeof children === 'string' ? children.trimEnd().split('\n') : []), [children]) + const fontSize = useMemo(() => customFontSize ?? _fontSize - 1, [customFontSize, _fontSize]) + const lineNumbers = useMemo(() => options?.lineNumbers ?? _lineNumbers, [options?.lineNumbers, _lineNumbers]) + + const rawLines = useMemo(() => (typeof value === 'string' ? value.trimEnd().split('\n') : []), [value]) // 计算行号数字位数 const gutterDigits = useMemo( - () => (codeShowLineNumbers ? Math.max(rawLines.length.toString().length, 1) : 0), - [codeShowLineNumbers, rawLines.length] + () => (lineNumbers ? Math.max(rawLines.length.toString().length, 1) : 0), + [lineNumbers, rawLines.length] ) // 设置 pre 标签属性 @@ -69,7 +118,7 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla const getScrollElement = useCallback(() => scrollerRef.current, []) const getItemKey = useCallback((index: number) => `${callerId}-${index}`, [callerId]) // `line-height: 1.6` 为全局样式,但是为了避免测量误差在这里取整 - const estimateSize = useCallback(() => Math.round((fontSize - 1) * 1.6), [fontSize]) + const estimateSize = useCallback(() => Math.round(fontSize * 1.6), [fontSize]) // 创建 virtualizer 实例 const virtualizer = useVirtualizer({ @@ -106,20 +155,19 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla }, [rawLines.length, onHeightChange]) return ( -
+
@@ -143,7 +191,7 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla
@@ -227,9 +275,8 @@ VirtualizedRow.displayName = 'VirtualizedRow' const ScrollContainer = styled.div<{ $wrap?: boolean - $expanded?: boolean + $expand?: boolean $lineHeight?: number - $height?: string | number }>` display: block; overflow-x: auto; @@ -245,7 +292,7 @@ const ScrollContainer = styled.div<{ line-height: ${(props) => props.$lineHeight}px; /* contain 优化 wrap 时滚动性能,will-change 优化 unwrap 时滚动性能 */ contain: ${(props) => (props.$wrap ? 'content' : 'none')}; - will-change: ${(props) => (!props.$wrap && !props.$expanded ? 'transform' : 'auto')}; + will-change: ${(props) => (!props.$wrap && !props.$expand ? 'transform' : 'auto')}; .line-number { width: var(--gutter-width, 1.2ch); diff --git a/src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx b/src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx index 2d61583787..87765a504b 100644 --- a/src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx +++ b/src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx @@ -71,8 +71,9 @@ describe('DraggableList', () => { }) it('should render nothing when list is empty', () => { + const emptyList: Array<{ id: string; name: string }> = [] render( - {}}> + {}}> {(item) =>
{item.name}
}
) diff --git a/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts b/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts index f2d2fe837f..a9ffd3d889 100644 --- a/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts +++ b/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts @@ -33,7 +33,7 @@ describe('useDraggableReorder', () => { originalList: mockOriginalList, filteredList: mockOriginalList, // 列表未过滤 onUpdate, - idKey: 'id' + itemKey: 'id' }) ) @@ -61,7 +61,7 @@ describe('useDraggableReorder', () => { originalList: mockOriginalList, filteredList, onUpdate, - idKey: 'id' + itemKey: 'id' }) ) @@ -89,7 +89,7 @@ describe('useDraggableReorder', () => { originalList: mockOriginalList, filteredList: mockOriginalList, onUpdate, - idKey: 'id' + itemKey: 'id' }) ) @@ -110,7 +110,7 @@ describe('useDraggableReorder', () => { originalList: mockOriginalList, filteredList: mockOriginalList, onUpdate, - idKey: 'id' + itemKey: 'id' }) ) @@ -136,7 +136,7 @@ describe('useDraggableReorder', () => { originalList: mockOriginalList, filteredList, onUpdate, - idKey: 'id' + itemKey: 'id' }) ) diff --git a/src/renderer/src/components/DraggableList/list.tsx b/src/renderer/src/components/DraggableList/list.tsx index b0e87bd2d2..fbb5f29762 100644 --- a/src/renderer/src/components/DraggableList/list.tsx +++ b/src/renderer/src/components/DraggableList/list.tsx @@ -9,7 +9,7 @@ import { ResponderProvided } from '@hello-pangea/dnd' import { droppableReorder } from '@renderer/utils' -import { FC, HTMLAttributes } from 'react' +import { HTMLAttributes, Key, useCallback } from 'react' interface Props { list: T[] @@ -17,23 +17,25 @@ interface Props { listStyle?: React.CSSProperties listProps?: HTMLAttributes children: (item: T, index: number) => React.ReactNode + itemKey?: keyof T | ((item: T) => Key) onUpdate: (list: T[]) => void onDragStart?: OnDragStartResponder onDragEnd?: OnDragEndResponder droppableProps?: Partial } -const DraggableList: FC> = ({ +function DraggableList({ children, list, style, listStyle, listProps, + itemKey, droppableProps, onDragStart, onUpdate, onDragEnd -}) => { +}: Props) { const _onDragEnd = (result: DropResult, provided: ResponderProvided) => { onDragEnd?.(result, provided) if (result.destination) { @@ -46,6 +48,17 @@ const DraggableList: FC> = ({ } } + const getId = useCallback( + (item: T) => { + if (typeof itemKey === 'function') return itemKey(item) + if (itemKey) return item[itemKey] as Key + if (typeof item === 'string') return item as Key + if (item && typeof item === 'object' && 'id' in item) return item.id as Key + return undefined + }, + [itemKey] + ) + return ( @@ -53,9 +66,9 @@ const DraggableList: FC> = ({
{list.map((item, index) => { - const id = item.id || item + const draggableId = String(getId(item) ?? index) return ( - + {(provided) => (
{ /** 用于更新原始列表状态的函数 */ onUpdate: (newList: T[]) => void /** 用于从列表项中获取唯一ID的属性名或函数 */ - idKey: keyof T | ((item: T) => Key) + itemKey: keyof T | ((item: T) => Key) } /** @@ -19,8 +19,16 @@ interface UseDraggableReorderParams { * @param params - { originalList, filteredList, onUpdate, idKey } * @returns 返回可以直接传递给 DraggableVirtualList 的 props: { onDragEnd, itemKey } */ -export function useDraggableReorder({ originalList, filteredList, onUpdate, idKey }: UseDraggableReorderParams) { - const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey]) +export function useDraggableReorder({ + originalList, + filteredList, + onUpdate, + itemKey +}: UseDraggableReorderParams) { + const getId = useCallback( + (item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as Key)), + [itemKey] + ) // 创建从 item ID 到其在 *原始列表* 中索引的映射 const itemIndexMap = useMemo(() => { diff --git a/src/renderer/src/components/DraggableList/virtual-list.tsx b/src/renderer/src/components/DraggableList/virtual-list.tsx index e915eec1fe..b2efe7c247 100644 --- a/src/renderer/src/components/DraggableList/virtual-list.tsx +++ b/src/renderer/src/components/DraggableList/virtual-list.tsx @@ -208,7 +208,7 @@ const VirtualRow = memo( const draggableId = String(virtualItem.key) return ( diff --git a/src/renderer/src/components/OGCard.tsx b/src/renderer/src/components/OGCard.tsx new file mode 100644 index 0000000000..8a0036e8e2 --- /dev/null +++ b/src/renderer/src/components/OGCard.tsx @@ -0,0 +1,145 @@ +import CherryLogo from '@renderer/assets/images/banner.png' +import Favicon from '@renderer/components/Icons/FallbackFavicon' +import { useMetaDataParser } from '@renderer/hooks/useMetaDataParser' +import { Skeleton, Typography } from 'antd' +import { useEffect, useMemo } from 'react' +import styled from 'styled-components' +const { Title, Paragraph } = Typography + +type Props = { + link: string + show: boolean +} + +export const OGCard = ({ link, show }: Props) => { + const openGraph = ['og:title', 'og:description', 'og:image', 'og:imageAlt'] as const + const { metadata, isLoading, parseMetadata } = useMetaDataParser(link, openGraph) + + const hasImage = !!metadata['og:image'] + + const hostname = useMemo(() => { + try { + return new URL(link).hostname + } catch { + return null + } + }, [link]) + + useEffect(() => { + // use show to lazy loading + if (show && isLoading) { + parseMetadata() + } + }, [parseMetadata, isLoading, show]) + + if (isLoading) { + return + } + + return ( + + {hasImage && ( + + + + )} + {!hasImage && ( + + + + )} + + + + {hostname && } + + {metadata['og:title'] || hostname} + + + + {metadata['og:description'] || link} + + + + ) +} + +const CardSkeleton = () => { + return ( + + + + + ) +} + +const StyledHyperLink = styled.div` + display: flex; + align-items: center; + gap: 8px; +` + +const PreviewContainer = styled.div<{ hasImage?: boolean }>` + display: flex; + flex-direction: column; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + width: 380px; + height: 220px; + overflow: hidden; +` + +const PreviewImageContainer = styled.div` + width: 100%; + height: 140px; + min-height: 140px; + overflow: hidden; +` + +const PreviewContent = styled.div` + padding: 12px 16px; + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; +` + +const PreviewImage = styled.img` + width: 100%; + height: 140px; + object-fit: cover; +` + +const SkeletonContainer = styled.div` + width: 380px; + height: 220px; + padding: 12px 16px; + display: flex; + flex-direction: column; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + gap: 16px; +` diff --git a/src/renderer/src/components/Preview/MermaidPreview.tsx b/src/renderer/src/components/Preview/MermaidPreview.tsx index 4c41e3c4b9..60708fbee8 100644 --- a/src/renderer/src/components/Preview/MermaidPreview.tsx +++ b/src/renderer/src/components/Preview/MermaidPreview.tsx @@ -56,6 +56,7 @@ const MermaidPreview = ({ document.body.removeChild(measureEl) } }, + // eslint-disable-next-line react-hooks/exhaustive-deps [diagramId, mermaid, forceRenderKey] ) diff --git a/src/renderer/src/components/dnd/useDndReorder.ts b/src/renderer/src/components/dnd/useDndReorder.ts index 1b91507cb5..60beaf925a 100644 --- a/src/renderer/src/components/dnd/useDndReorder.ts +++ b/src/renderer/src/components/dnd/useDndReorder.ts @@ -8,7 +8,7 @@ interface UseDndReorderParams { /** 用于更新原始列表状态的函数 */ onUpdate: (newList: T[]) => void /** 用于从列表项中获取唯一ID的属性名或函数 */ - idKey: keyof T | ((item: T) => Key) + itemKey: keyof T | ((item: T) => Key) } /** @@ -18,8 +18,11 @@ interface UseDndReorderParams { * @param params - { originalList, filteredList, onUpdate, idKey } * @returns 返回可以直接传递给 Sortable 的 onSortEnd 回调 */ -export function useDndReorder({ originalList, filteredList, onUpdate, idKey }: UseDndReorderParams) { - const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey]) +export function useDndReorder({ originalList, filteredList, onUpdate, itemKey }: UseDndReorderParams) { + const getId = useCallback( + (item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as Key)), + [itemKey] + ) // 创建从 item ID 到其在 *原始列表* 中索引的映射 const itemIndexMap = useMemo(() => { diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 5e97206aa6..189c5b3e3c 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -739,6 +739,12 @@ export const SYSTEM_MODELS: Record = provider: 'gemini', name: 'Gemini 2.0 Flash', group: 'Gemini 2.0' + }, + { + id: 'gemini-2.5-flash-image-preview', + provider: 'gemini', + name: 'Gemini 2.5 Flash Image', + group: 'Gemini 2.5' } ], anthropic: [ @@ -1564,6 +1570,12 @@ export const SYSTEM_MODELS: Record = } ], openrouter: [ + { + id: 'google/gemini-2.5-flash-image-preview', + provider: 'openrouter', + name: 'Google: Gemini 2.5 Flash Image', + group: 'google' + }, { id: 'google/gemini-2.5-flash-preview', provider: 'openrouter', @@ -2320,6 +2332,9 @@ export const DEDICATED_IMAGE_MODELS = [ 'gpt-image-1' ] +// Models that should auto-enable image generation button when selected +export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image-preview', ...DEDICATED_IMAGE_MODELS] + export const OPENAI_IMAGE_GENERATION_MODELS = [ 'o3', 'gpt-4o', @@ -2340,8 +2355,17 @@ export const GENERATE_IMAGE_MODELS = [ ] export const isDedicatedImageGenerationModel = (model: Model): boolean => { + if (!model) return false + const modelId = getLowerBaseModelName(model.id) - return DEDICATED_IMAGE_MODELS.filter((m) => modelId.includes(m)).length > 0 + return DEDICATED_IMAGE_MODELS.some((m) => modelId.includes(m)) +} + +export const isAutoEnableImageGenerationModel = (model: Model): boolean => { + if (!model) return false + + const modelId = getLowerBaseModelName(model.id) + return AUTO_ENABLE_IMAGE_MODELS.some((m) => modelId.includes(m)) } export function isGenerateImageModel(model: Model): boolean { diff --git a/src/renderer/src/hooks/useMetaDataParser.ts b/src/renderer/src/hooks/useMetaDataParser.ts new file mode 100644 index 0000000000..10585b1b13 --- /dev/null +++ b/src/renderer/src/hooks/useMetaDataParser.ts @@ -0,0 +1,78 @@ +import axios from 'axios' +import * as htmlparser2 from 'htmlparser2' +import { useCallback, useEffect, useRef, useState } from 'react' + +export function useMetaDataParser( + link: string, + properties: readonly T[], + options?: { + timeout?: number + } +) { + const { timeout = 5000 } = options || {} + + const [metadata, setMetadata] = useState>({} as Record) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const abortControllerRef = useRef(null) + + const parseMetadata = useCallback(async () => { + if (!link || !isLoading) return + + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + + const controller = new AbortController() + abortControllerRef.current = controller + + setIsLoading(true) + setError(null) + + try { + const response = await axios.get(link, { timeout, signal: controller.signal }) + + const htmlContent = response.data + const parsedMetadata = {} as Record + + const parser = new htmlparser2.Parser({ + onopentag(tagName, attributes) { + if (tagName === 'meta') { + const { name: metaName, property: metaProperty, content } = attributes + const metaKey = metaName || metaProperty + if (!metaKey || !properties.includes(metaKey as T)) return + parsedMetadata[metaKey as T] = content + } + } + }) + + parser.parseComplete(htmlContent) + + setMetadata(parsedMetadata) + } catch (err) { + // Don't set error if request was aborted + if (axios.isCancel(err) || (err instanceof Error && err.name === 'AbortError')) { + return + } + setError(err instanceof Error ? err : new Error('Failed to fetch HTML')) + } finally { + setIsLoading(false) + } + }, [isLoading, link, properties, timeout]) + + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + } + }, []) + + return { + metadata, + isLoading, + error, + parseMetadata + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index c6b745ac85..365b0797d1 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1552,6 +1552,7 @@ "selected": "Selected tags" }, "function_calling": "Function Calling", + "invalid_model": "Invalid Model", "no_matches": "No models available", "parameter_name": "Parameter Name", "parameter_type": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index a00750d71e..51a9f0d799 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1552,6 +1552,7 @@ "selected": "選択済みのタグ" }, "function_calling": "関数呼び出し", + "invalid_model": "無効なモデル", "no_matches": "利用可能なモデルがありません", "parameter_name": "パラメータ名", "parameter_type": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index d6c6d4d198..0fc5d567ac 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1552,6 +1552,7 @@ "selected": "Выбранные теги" }, "function_calling": "Вызов функции", + "invalid_model": "Недействительная модель", "no_matches": "Нет доступных моделей", "parameter_name": "Имя параметра", "parameter_type": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index bf3215a35c..feb7338be3 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1552,6 +1552,7 @@ "selected": "已选标签" }, "function_calling": "函数调用", + "invalid_model": "无效模型", "no_matches": "无可用模型", "parameter_name": "参数名称", "parameter_type": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 93881b95f9..b8337ac2c1 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1552,6 +1552,7 @@ "selected": "已選標籤" }, "function_calling": "函數調用", + "invalid_model": "無效模型", "no_matches": "無可用模型", "parameter_name": "參數名稱", "parameter_type": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 43602afe6f..e5cb29ec8a 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -1550,6 +1550,7 @@ "selected": "Επιλεγμένη ετικέτα" }, "function_calling": "Ξεχωριστική Κλήση Συναρτήσεων", + "invalid_model": "Μη έγκυρο μοντέλο", "no_matches": "Δεν υπάρχουν διαθέσιμα μοντέλα", "parameter_name": "Όνομα παραμέτρου", "parameter_type": { diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 715401149f..6924a3731c 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -1550,6 +1550,7 @@ "selected": "Etiquetas seleccionadas" }, "function_calling": "Llamada a función", + "invalid_model": "Modelo inválido", "no_matches": "No hay modelos disponibles", "parameter_name": "Nombre del parámetro", "parameter_type": { diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 794c76a35e..d3e216b809 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -1550,6 +1550,7 @@ "selected": "Étiquette sélectionnée" }, "function_calling": "Appel de fonction", + "invalid_model": "Modèle invalide", "no_matches": "Aucun modèle disponible", "parameter_name": "Nom du paramètre", "parameter_type": { diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 57d26464e8..1595318214 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -1550,6 +1550,7 @@ "selected": "Etiqueta selecionada" }, "function_calling": "Chamada de função", + "invalid_model": "Modelo inválido", "no_matches": "Nenhum modelo disponível", "parameter_name": "Nome do parâmetro", "parameter_type": { diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 54655a9815..e7f1163c21 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -4,7 +4,7 @@ import { loggerService } from '@logger' import { QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel' import TranslateButton from '@renderer/components/TranslateButton' import { - isDedicatedImageGenerationModel, + isAutoEnableImageGenerationModel, isGenerateImageModel, isGenerateImageModels, isMandatoryWebSearchModel, @@ -783,7 +783,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = if (!isGenerateImageModel(model) && assistant.enableGenerateImage) { updateAssistant({ ...assistant, enableGenerateImage: false }) } - if (isDedicatedImageGenerationModel(model) && !assistant.enableGenerateImage) { + if (isAutoEnableImageGenerationModel(model) && !assistant.enableGenerateImage) { updateAssistant({ ...assistant, enableGenerateImage: true }) } }, [assistant, model, updateAssistant]) diff --git a/src/renderer/src/pages/home/Markdown/Hyperlink.tsx b/src/renderer/src/pages/home/Markdown/Hyperlink.tsx index 6e461ea1a6..ec157270c8 100644 --- a/src/renderer/src/pages/home/Markdown/Hyperlink.tsx +++ b/src/renderer/src/pages/home/Markdown/Hyperlink.tsx @@ -1,13 +1,15 @@ -import Favicon from '@renderer/components/Icons/FallbackFavicon' +import { OGCard } from '@renderer/components/OGCard' import { Popover } from 'antd' -import React, { memo, useMemo } from 'react' -import styled from 'styled-components' +import React, { memo, useMemo, useState } from 'react' interface HyperLinkProps { children: React.ReactNode href: string } + const Hyperlink: React.FC = ({ children, href }) => { + const [open, setOpen] = useState(false) + const link = useMemo(() => { try { return decodeURIComponent(href) @@ -16,32 +18,20 @@ const Hyperlink: React.FC = ({ children, href }) => { } }, [href]) - const hostname = useMemo(() => { - try { - return new URL(link).hostname - } catch { - return null - } - }, [link]) - if (!href) return children return ( - {hostname && } - {link} - - } + open={open} + onOpenChange={setOpen} + content={} placement="top" - color="var(--color-background)" styles={{ body: { - border: '1px solid var(--color-border)', - padding: '12px', - borderRadius: '8px' + padding: 0, + borderRadius: '8px', + overflow: 'hidden' } }}> {children} @@ -49,17 +39,4 @@ const Hyperlink: React.FC = ({ children, href }) => { ) } -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) diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 4ead23f46a..c7bc25df96 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -141,7 +141,7 @@ const Markdown: FC = ({ block, postProcess }) => { } as Partial }, [block.id]) - if (messageContent.includes('