mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 12:51:26 +08:00
Merge remote-tracking branch 'origin/main' into feat/aisdk-package
This commit is contained in:
commit
5aa8f3901f
@ -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 })] : []
|
||||
}
|
||||
@ -26,20 +28,7 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'@libsql/client',
|
||||
'bufferutil',
|
||||
'utf-8-validate',
|
||||
'jsdom',
|
||||
'electron',
|
||||
'graceful-fs',
|
||||
'selection-hook',
|
||||
'@napi-rs/system-ocr',
|
||||
'@strongtz/win32-arm64-msvc',
|
||||
'os-proxy-config',
|
||||
'sharp',
|
||||
'turndown'
|
||||
],
|
||||
external: ['bufferutil', 'utf-8-validate', 'electron', ...Object.keys(pkg.dependencies)],
|
||||
output: {
|
||||
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
|
||||
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
|
||||
|
||||
@ -75,7 +75,6 @@
|
||||
"@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",
|
||||
"htmlparser2": "^10.0.0",
|
||||
"jsdom": "26.1.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"officeparser": "^4.2.0",
|
||||
|
||||
@ -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": ""
|
||||
},
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -257,12 +257,13 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
) : (
|
||||
<CodeViewer
|
||||
className="source-view"
|
||||
value={children}
|
||||
language={language}
|
||||
onHeightChange={handleHeightChange}
|
||||
expanded={shouldExpand}
|
||||
wrapped={shouldWrap}
|
||||
onHeightChange={handleHeightChange}>
|
||||
{children}
|
||||
</CodeViewer>
|
||||
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
|
||||
/>
|
||||
),
|
||||
[children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldWrap]
|
||||
)
|
||||
|
||||
@ -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,
|
||||
@ -121,7 +121,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),
|
||||
@ -129,7 +129,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 ?? ''))
|
||||
@ -214,10 +214,10 @@ const CodeEditor = ({
|
||||
foldKeymap: enableKeymap,
|
||||
completionKeymap: enableKeymap,
|
||||
lintKeymap: enableKeymap,
|
||||
...customBasicSetup // override basicSetup
|
||||
...basicSetup // override basicSetup
|
||||
}}
|
||||
style={{
|
||||
fontSize: customFontSize,
|
||||
fontSize,
|
||||
marginTop: 0,
|
||||
borderRadius: 'inherit',
|
||||
...style
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@ -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: React.ReactNode
|
||||
expanded?: boolean
|
||||
wrapped?: boolean
|
||||
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,19 +61,33 @@ interface CodeViewerProps {
|
||||
* - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应
|
||||
* - 并发安全
|
||||
*/
|
||||
const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, className, height }: CodeViewerProps) => {
|
||||
const { codeShowLineNumbers, fontSize } = useSettings()
|
||||
const CodeViewer = ({
|
||||
value,
|
||||
language,
|
||||
height,
|
||||
maxHeight,
|
||||
onHeightChange,
|
||||
options,
|
||||
fontSize: customFontSize,
|
||||
className,
|
||||
expanded = true,
|
||||
wrapped = true
|
||||
}: CodeViewerProps) => {
|
||||
const { codeShowLineNumbers: _lineNumbers, fontSize: _fontSize } = useSettings()
|
||||
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
|
||||
const shikiThemeRef = useRef<HTMLDivElement>(null)
|
||||
const scrollerRef = useRef<HTMLDivElement>(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 标签属性
|
||||
@ -68,7 +117,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({
|
||||
@ -105,20 +154,19 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla
|
||||
}, [rawLines.length, onHeightChange])
|
||||
|
||||
return (
|
||||
<div ref={shikiThemeRef} style={height ? { height } : undefined}>
|
||||
<div ref={shikiThemeRef} style={expanded ? undefined : { height }}>
|
||||
<ScrollContainer
|
||||
ref={scrollerRef}
|
||||
className="shiki-scroller"
|
||||
$wrap={wrapped}
|
||||
$expanded={expanded}
|
||||
$expand={expanded}
|
||||
$lineHeight={estimateSize()}
|
||||
$height={height}
|
||||
style={
|
||||
{
|
||||
'--gutter-width': `${gutterDigits}ch`,
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
maxHeight: expanded ? undefined : height ? undefined : MAX_COLLAPSED_CODE_HEIGHT,
|
||||
height: height,
|
||||
fontSize,
|
||||
height: expanded ? undefined : height,
|
||||
maxHeight: expanded ? undefined : maxHeight,
|
||||
overflowY: expanded ? 'hidden' : 'auto'
|
||||
} as React.CSSProperties
|
||||
}>
|
||||
@ -142,7 +190,7 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla
|
||||
<VirtualizedRow
|
||||
rawLine={rawLines[virtualItem.index]}
|
||||
tokenLine={tokenLines[virtualItem.index]}
|
||||
showLineNumbers={codeShowLineNumbers}
|
||||
showLineNumbers={lineNumbers}
|
||||
index={virtualItem.index}
|
||||
/>
|
||||
</div>
|
||||
@ -226,9 +274,8 @@ VirtualizedRow.displayName = 'VirtualizedRow'
|
||||
|
||||
const ScrollContainer = styled.div<{
|
||||
$wrap?: boolean
|
||||
$expanded?: boolean
|
||||
$expand?: boolean
|
||||
$lineHeight?: number
|
||||
$height?: string | number
|
||||
}>`
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
@ -244,7 +291,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);
|
||||
|
||||
@ -71,8 +71,9 @@ describe('DraggableList', () => {
|
||||
})
|
||||
|
||||
it('should render nothing when list is empty', () => {
|
||||
const emptyList: Array<{ id: string; name: string }> = []
|
||||
render(
|
||||
<DraggableList list={[]} onUpdate={() => {}}>
|
||||
<DraggableList list={emptyList} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DraggableList>
|
||||
)
|
||||
|
||||
@ -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'
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@ -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<T> {
|
||||
list: T[]
|
||||
@ -17,23 +17,25 @@ interface Props<T> {
|
||||
listStyle?: React.CSSProperties
|
||||
listProps?: HTMLAttributes<HTMLDivElement>
|
||||
children: (item: T, index: number) => React.ReactNode
|
||||
itemKey?: keyof T | ((item: T) => Key)
|
||||
onUpdate: (list: T[]) => void
|
||||
onDragStart?: OnDragStartResponder
|
||||
onDragEnd?: OnDragEndResponder
|
||||
droppableProps?: Partial<DroppableProps>
|
||||
}
|
||||
|
||||
const DraggableList: FC<Props<any>> = ({
|
||||
function DraggableList<T>({
|
||||
children,
|
||||
list,
|
||||
style,
|
||||
listStyle,
|
||||
listProps,
|
||||
itemKey,
|
||||
droppableProps,
|
||||
onDragStart,
|
||||
onUpdate,
|
||||
onDragEnd
|
||||
}) => {
|
||||
}: Props<T>) {
|
||||
const _onDragEnd = (result: DropResult, provided: ResponderProvided) => {
|
||||
onDragEnd?.(result, provided)
|
||||
if (result.destination) {
|
||||
@ -46,6 +48,17 @@ const DraggableList: FC<Props<any>> = ({
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
|
||||
<Droppable droppableId="droppable" {...droppableProps}>
|
||||
@ -53,9 +66,9 @@ const DraggableList: FC<Props<any>> = ({
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} style={style}>
|
||||
<div {...listProps} className="draggable-list-container">
|
||||
{list.map((item, index) => {
|
||||
const id = item.id || item
|
||||
const draggableId = String(getId(item) ?? index)
|
||||
return (
|
||||
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
|
||||
<Draggable key={`draggable_${draggableId}`} draggableId={draggableId} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
|
||||
@ -9,7 +9,7 @@ interface UseDraggableReorderParams<T> {
|
||||
/** 用于更新原始列表状态的函数 */
|
||||
onUpdate: (newList: T[]) => void
|
||||
/** 用于从列表项中获取唯一ID的属性名或函数 */
|
||||
idKey: keyof T | ((item: T) => Key)
|
||||
itemKey: keyof T | ((item: T) => Key)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -19,8 +19,16 @@ interface UseDraggableReorderParams<T> {
|
||||
* @param params - { originalList, filteredList, onUpdate, idKey }
|
||||
* @returns 返回可以直接传递给 DraggableVirtualList 的 props: { onDragEnd, itemKey }
|
||||
*/
|
||||
export function useDraggableReorder<T>({ originalList, filteredList, onUpdate, idKey }: UseDraggableReorderParams<T>) {
|
||||
const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey])
|
||||
export function useDraggableReorder<T>({
|
||||
originalList,
|
||||
filteredList,
|
||||
onUpdate,
|
||||
itemKey
|
||||
}: UseDraggableReorderParams<T>) {
|
||||
const getId = useCallback(
|
||||
(item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as Key)),
|
||||
[itemKey]
|
||||
)
|
||||
|
||||
// 创建从 item ID 到其在 *原始列表* 中索引的映射
|
||||
const itemIndexMap = useMemo(() => {
|
||||
|
||||
@ -208,7 +208,7 @@ const VirtualRow = memo(
|
||||
const draggableId = String(virtualItem.key)
|
||||
return (
|
||||
<Draggable
|
||||
key={`draggable_${draggableId}_${virtualItem.index}`}
|
||||
key={`draggable_${draggableId}`}
|
||||
draggableId={draggableId}
|
||||
isDragDisabled={disabled}
|
||||
index={virtualItem.index}>
|
||||
|
||||
@ -56,6 +56,7 @@ const MermaidPreview = ({
|
||||
document.body.removeChild(measureEl)
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[diagramId, mermaid, forceRenderKey]
|
||||
)
|
||||
|
||||
|
||||
20
src/renderer/src/components/TooltipIcons/HelpTooltip.tsx
Normal file
20
src/renderer/src/components/TooltipIcons/HelpTooltip.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Tooltip, TooltipProps } from 'antd'
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
|
||||
type InheritedTooltipProps = Omit<TooltipProps, 'children'>
|
||||
|
||||
interface HelpTooltipProps extends InheritedTooltipProps {
|
||||
iconColor?: string
|
||||
iconSize?: string | number
|
||||
iconStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
const HelpTooltip = ({ iconColor = 'var(--color-text-2)', iconSize = 14, iconStyle, ...rest }: HelpTooltipProps) => {
|
||||
return (
|
||||
<Tooltip {...rest}>
|
||||
<HelpCircle size={iconSize} color={iconColor} style={{ ...iconStyle }} role="img" aria-label="Help" />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default HelpTooltip
|
||||
@ -9,7 +9,7 @@ interface InfoTooltipProps extends InheritedTooltipProps {
|
||||
iconStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
const InfoTooltip = ({ iconColor = 'var(--color-text-3)', iconSize = 14, iconStyle, ...rest }: InfoTooltipProps) => {
|
||||
const InfoTooltip = ({ iconColor = 'var(--color-text-2)', iconSize = 14, iconStyle, ...rest }: InfoTooltipProps) => {
|
||||
return (
|
||||
<Tooltip {...rest}>
|
||||
<Info size={iconSize} color={iconColor} style={{ ...iconStyle }} role="img" aria-label="Information" />
|
||||
3
src/renderer/src/components/TooltipIcons/index.ts
Normal file
3
src/renderer/src/components/TooltipIcons/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as HelpTooltip } from './HelpTooltip'
|
||||
export { default as InfoTooltip } from './InfoTooltip'
|
||||
export { default as WarnTooltip } from './WarnTooltip'
|
||||
@ -8,7 +8,7 @@ interface UseDndReorderParams<T> {
|
||||
/** 用于更新原始列表状态的函数 */
|
||||
onUpdate: (newList: T[]) => void
|
||||
/** 用于从列表项中获取唯一ID的属性名或函数 */
|
||||
idKey: keyof T | ((item: T) => Key)
|
||||
itemKey: keyof T | ((item: T) => Key)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -18,8 +18,11 @@ interface UseDndReorderParams<T> {
|
||||
* @param params - { originalList, filteredList, onUpdate, idKey }
|
||||
* @returns 返回可以直接传递给 Sortable 的 onSortEnd 回调
|
||||
*/
|
||||
export function useDndReorder<T>({ originalList, filteredList, onUpdate, idKey }: UseDndReorderParams<T>) {
|
||||
const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey])
|
||||
export function useDndReorder<T>({ originalList, filteredList, onUpdate, itemKey }: UseDndReorderParams<T>) {
|
||||
const getId = useCallback(
|
||||
(item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as Key)),
|
||||
[itemKey]
|
||||
)
|
||||
|
||||
// 创建从 item ID 到其在 *原始列表* 中索引的映射
|
||||
const itemIndexMap = useMemo(() => {
|
||||
|
||||
14
src/renderer/src/hooks/useShowWorkspace.ts
Normal file
14
src/renderer/src/hooks/useShowWorkspace.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { selectNotesSettings, updateNotesSettings } from '@renderer/store/note'
|
||||
|
||||
export function useShowWorkspace() {
|
||||
const dispatch = useAppDispatch()
|
||||
const settings = useAppSelector(selectNotesSettings)
|
||||
const showWorkspace = settings.showWorkspace
|
||||
|
||||
return {
|
||||
showWorkspace,
|
||||
setShowWorkspace: (show: boolean) => dispatch(updateNotesSettings({ showWorkspace: show })),
|
||||
toggleShowWorkspace: () => dispatch(updateNotesSettings({ showWorkspace: !showWorkspace }))
|
||||
}
|
||||
}
|
||||
@ -3,10 +3,8 @@ import {
|
||||
setAssistantsTabSortType,
|
||||
setShowAssistants,
|
||||
setShowTopics,
|
||||
setShowWorkspace,
|
||||
toggleShowAssistants,
|
||||
toggleShowTopics,
|
||||
toggleShowWorkspace
|
||||
toggleShowTopics
|
||||
} from '@renderer/store/settings'
|
||||
import { AssistantsSortType } from '@renderer/types'
|
||||
|
||||
@ -41,14 +39,3 @@ export function useAssistantsTabSortType() {
|
||||
setAssistantsTabSortType: (sortType: AssistantsSortType) => dispatch(setAssistantsTabSortType(sortType))
|
||||
}
|
||||
}
|
||||
|
||||
export function useShowWorkspace() {
|
||||
const showWorkspace = useAppSelector((state) => state.settings.showWorkspace)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
showWorkspace,
|
||||
setShowWorkspace: (show: boolean) => dispatch(setShowWorkspace(show)),
|
||||
toggleShowWorkspace: () => dispatch(toggleShowWorkspace())
|
||||
}
|
||||
}
|
||||
|
||||
@ -538,7 +538,10 @@
|
||||
"tip": "The run button will be displayed in the toolbar of executable code blocks, please do not execute dangerous code!",
|
||||
"title": "Code Execution"
|
||||
},
|
||||
"code_image_tools": "Enable preview tools",
|
||||
"code_image_tools": {
|
||||
"label": "Enable preview tools",
|
||||
"tip": "Enable preview tools for images rendered from code blocks such as mermaid"
|
||||
},
|
||||
"code_wrappable": "Code block wrappable",
|
||||
"context_count": {
|
||||
"label": "Context",
|
||||
@ -1560,6 +1563,7 @@
|
||||
"selected": "Selected tags"
|
||||
},
|
||||
"function_calling": "Function Calling",
|
||||
"invalid_model": "Invalid Model",
|
||||
"no_matches": "No models available",
|
||||
"parameter_name": "Parameter Name",
|
||||
"parameter_type": {
|
||||
@ -1633,6 +1637,7 @@
|
||||
"only_markdown": "Only Markdown files are supported",
|
||||
"only_one_file_allowed": "Only one file can be uploaded",
|
||||
"open_folder": "Open an external folder",
|
||||
"open_outside": "Open from external",
|
||||
"rename": "Rename",
|
||||
"rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}",
|
||||
"save": "Save to Notes",
|
||||
|
||||
@ -538,7 +538,10 @@
|
||||
"tip": "実行可能なコードブロックのツールバーには実行ボタンが表示されます。危険なコードを実行しないでください!",
|
||||
"title": "コード実行"
|
||||
},
|
||||
"code_image_tools": "プレビューツールを有効にする",
|
||||
"code_image_tools": {
|
||||
"label": "プレビューツールを有効にする",
|
||||
"tip": "mermaid などのコードブロックから生成された画像に対してプレビューツールを有効にする"
|
||||
},
|
||||
"code_wrappable": "コードブロック折り返し",
|
||||
"context_count": {
|
||||
"label": "コンテキスト",
|
||||
@ -1560,6 +1563,7 @@
|
||||
"selected": "選択済みのタグ"
|
||||
},
|
||||
"function_calling": "関数呼び出し",
|
||||
"invalid_model": "無効なモデル",
|
||||
"no_matches": "利用可能なモデルがありません",
|
||||
"parameter_name": "パラメータ名",
|
||||
"parameter_type": {
|
||||
@ -1633,6 +1637,7 @@
|
||||
"only_markdown": "Markdown ファイルのみをアップロードできます",
|
||||
"only_one_file_allowed": "アップロードできるファイルは1つだけです",
|
||||
"open_folder": "外部フォルダーを開きます",
|
||||
"open_outside": "外部から開く",
|
||||
"rename": "名前の変更",
|
||||
"rename_changed": "セキュリティポリシーにより、ファイル名は{{original}}から{{final}}に変更されました",
|
||||
"save": "メモに保存する",
|
||||
|
||||
@ -538,7 +538,10 @@
|
||||
"tip": "Выполнение кода в блоке кода возможно, но не рекомендуется выполнять опасный код!",
|
||||
"title": "Выполнение кода"
|
||||
},
|
||||
"code_image_tools": "Включить инструменты предпросмотра",
|
||||
"code_image_tools": {
|
||||
"label": "Включить инструменты предпросмотра",
|
||||
"tip": "Включить инструменты предпросмотра для изображений, сгенерированных из блоков кода (например mermaid)"
|
||||
},
|
||||
"code_wrappable": "Блок кода можно переносить",
|
||||
"context_count": {
|
||||
"label": "Контекст",
|
||||
@ -1560,6 +1563,7 @@
|
||||
"selected": "Выбранные теги"
|
||||
},
|
||||
"function_calling": "Вызов функции",
|
||||
"invalid_model": "Недействительная модель",
|
||||
"no_matches": "Нет доступных моделей",
|
||||
"parameter_name": "Имя параметра",
|
||||
"parameter_type": {
|
||||
@ -1633,6 +1637,7 @@
|
||||
"only_markdown": "Только Markdown",
|
||||
"only_one_file_allowed": "Можно загрузить только один файл",
|
||||
"open_folder": "Откройте внешнюю папку",
|
||||
"open_outside": "открыть снаружи",
|
||||
"rename": "переименовать",
|
||||
"rename_changed": "В связи с политикой безопасности имя файла было изменено с {{Original}} на {{final}}",
|
||||
"save": "Сохранить в заметки",
|
||||
|
||||
@ -538,7 +538,10 @@
|
||||
"tip": "可执行的代码块工具栏中会显示运行按钮,注意不要执行危险代码!",
|
||||
"title": "代码执行"
|
||||
},
|
||||
"code_image_tools": "启用预览工具",
|
||||
"code_image_tools": {
|
||||
"label": "启用预览工具",
|
||||
"tip": "为 mermaid 等代码块渲染后的图像启用预览工具"
|
||||
},
|
||||
"code_wrappable": "代码块可换行",
|
||||
"context_count": {
|
||||
"label": "上下文数",
|
||||
@ -1560,6 +1563,7 @@
|
||||
"selected": "已选标签"
|
||||
},
|
||||
"function_calling": "函数调用",
|
||||
"invalid_model": "无效模型",
|
||||
"no_matches": "无可用模型",
|
||||
"parameter_name": "参数名称",
|
||||
"parameter_type": {
|
||||
@ -1633,6 +1637,7 @@
|
||||
"only_markdown": "仅支持 Markdown 格式",
|
||||
"only_one_file_allowed": "只能上传一个文件",
|
||||
"open_folder": "打开外部文件夹",
|
||||
"open_outside": "从外部打开",
|
||||
"rename": "重命名",
|
||||
"rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}",
|
||||
"save": "保存到笔记",
|
||||
|
||||
@ -538,7 +538,10 @@
|
||||
"tip": "可執行的程式碼塊工具欄中會顯示運行按鈕,注意不要執行危險程式碼!",
|
||||
"title": "程式碼執行"
|
||||
},
|
||||
"code_image_tools": "啟用預覽工具",
|
||||
"code_image_tools": {
|
||||
"label": "啟用預覽工具",
|
||||
"tip": "為 mermaid 等程式碼區塊渲染後的圖像啟用預覽工具"
|
||||
},
|
||||
"code_wrappable": "程式碼區塊可自動換行",
|
||||
"context_count": {
|
||||
"label": "上下文",
|
||||
@ -1560,6 +1563,7 @@
|
||||
"selected": "已選標籤"
|
||||
},
|
||||
"function_calling": "函數調用",
|
||||
"invalid_model": "無效模型",
|
||||
"no_matches": "無可用模型",
|
||||
"parameter_name": "參數名稱",
|
||||
"parameter_type": {
|
||||
@ -1633,6 +1637,7 @@
|
||||
"only_markdown": "僅支援 Markdown 格式",
|
||||
"only_one_file_allowed": "只能上傳一個文件",
|
||||
"open_folder": "打開外部文件夾",
|
||||
"open_outside": "從外部打開",
|
||||
"rename": "重命名",
|
||||
"rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}",
|
||||
"save": "儲存到筆記",
|
||||
|
||||
@ -677,6 +677,7 @@
|
||||
"model_placeholder": "Επιλέξτε το μοντέλο που θα χρησιμοποιήσετε",
|
||||
"model_required": "Επιλέξτε μοντέλο",
|
||||
"select_folder": "Επιλογή φακέλου",
|
||||
"supported_providers": "υποστηριζόμενοι πάροχοι",
|
||||
"title": "Εργαλεία κώδικα",
|
||||
"update_options": "Ενημέρωση επιλογών",
|
||||
"working_directory": "κατάλογος εργασίας"
|
||||
@ -1319,7 +1320,8 @@
|
||||
"delete": {
|
||||
"content": "Η διαγραφή της ομάδας θα διαγράψει τις ερωτήσεις των χρηστών και όλες τις απαντήσεις του αστρόναυτη",
|
||||
"title": "Διαγραφή ομάδας"
|
||||
}
|
||||
},
|
||||
"retry_failed": "Αποτυχημένο μήνυμα επανάληψης"
|
||||
},
|
||||
"ignore": {
|
||||
"knowledge": {
|
||||
@ -1550,6 +1552,7 @@
|
||||
"selected": "Επιλεγμένη ετικέτα"
|
||||
},
|
||||
"function_calling": "Ξεχωριστική Κλήση Συναρτήσεων",
|
||||
"invalid_model": "Μη έγκυρο μοντέλο",
|
||||
"no_matches": "Δεν υπάρχουν διαθέσιμα μοντέλα",
|
||||
"parameter_name": "Όνομα παραμέτρου",
|
||||
"parameter_type": {
|
||||
@ -1619,9 +1622,13 @@
|
||||
"new_folder": "Νέος φάκελος",
|
||||
"new_note": "Δημιουργία νέας σημείωσης",
|
||||
"no_content_to_copy": "Δεν υπάρχει περιεχόμενο προς αντιγραφή",
|
||||
"no_file_selected": "Επιλέξτε το αρχείο για μεταφόρτωση",
|
||||
"only_markdown": "Υποστηρίζεται μόνο η μορφή Markdown",
|
||||
"only_one_file_allowed": "Μπορείτε να ανεβάσετε μόνο ένα αρχείο",
|
||||
"open_folder": "Άνοιγμα εξωτερικού φακέλου",
|
||||
"open_outside": "Από το εξωτερικό",
|
||||
"rename": "μετονομασία",
|
||||
"rename_changed": "Λόγω πολιτικής ασφάλειας, το όνομα του αρχείου έχει αλλάξει από {{original}} σε {{final}}",
|
||||
"save": "αποθήκευση στις σημειώσεις",
|
||||
"settings": {
|
||||
"data": {
|
||||
@ -3343,6 +3350,8 @@
|
||||
"label": "Καταγραφή στοιχείων στο grid"
|
||||
},
|
||||
"input": {
|
||||
"confirm_delete_message": "Επιβεβαίωση πριν τη διαγραφή μηνύματος",
|
||||
"confirm_regenerate_message": "Επιβεβαίωση πριν από την επαναδημιουργία του μηνύματος",
|
||||
"enable_quick_triggers": "Ενεργοποίηση των '/' και '@' για γρήγορη πρόσβαση σε μενού",
|
||||
"paste_long_text_as_file": "Επικόλληση μεγάλου κειμένου ως αρχείο",
|
||||
"paste_long_text_threshold": "Όριο μεγάλου κειμένου",
|
||||
|
||||
@ -677,6 +677,7 @@
|
||||
"model_placeholder": "Seleccionar el modelo que se va a utilizar",
|
||||
"model_required": "Seleccione el modelo",
|
||||
"select_folder": "Seleccionar carpeta",
|
||||
"supported_providers": "Proveedores de servicios compatibles",
|
||||
"title": "Herramientas de código",
|
||||
"update_options": "Opciones de actualización",
|
||||
"working_directory": "directorio de trabajo"
|
||||
@ -1319,7 +1320,8 @@
|
||||
"delete": {
|
||||
"content": "Eliminar el mensaje del grupo eliminará la pregunta del usuario y todas las respuestas del asistente",
|
||||
"title": "Eliminar mensaje del grupo"
|
||||
}
|
||||
},
|
||||
"retry_failed": "Reintentar el mensaje con error"
|
||||
},
|
||||
"ignore": {
|
||||
"knowledge": {
|
||||
@ -1550,6 +1552,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": {
|
||||
@ -1619,9 +1622,13 @@
|
||||
"new_folder": "Nueva carpeta",
|
||||
"new_note": "Crear nota nueva",
|
||||
"no_content_to_copy": "No hay contenido para copiar",
|
||||
"no_file_selected": "Por favor, seleccione el archivo a subir",
|
||||
"only_markdown": "Solo se admite el formato Markdown",
|
||||
"only_one_file_allowed": "solo se puede subir un archivo",
|
||||
"open_folder": "abrir carpeta externa",
|
||||
"open_outside": "Abrir desde el exterior",
|
||||
"rename": "renombrar",
|
||||
"rename_changed": "Debido a políticas de seguridad, el nombre del archivo ha cambiado de {{original}} a {{final}}",
|
||||
"save": "Guardar en notas",
|
||||
"settings": {
|
||||
"data": {
|
||||
@ -3343,6 +3350,8 @@
|
||||
"label": "Desencadenante de detalles de cuadrícula"
|
||||
},
|
||||
"input": {
|
||||
"confirm_delete_message": "Confirmar antes de eliminar mensaje",
|
||||
"confirm_regenerate_message": "confirmar antes de regenerar el mensaje",
|
||||
"enable_quick_triggers": "Habilitar menú rápido con '/' y '@'",
|
||||
"paste_long_text_as_file": "Pegar texto largo como archivo",
|
||||
"paste_long_text_threshold": "Límite de longitud de texto largo",
|
||||
|
||||
@ -677,6 +677,7 @@
|
||||
"model_placeholder": "Sélectionnez le modèle à utiliser",
|
||||
"model_required": "Veuillez sélectionner le modèle",
|
||||
"select_folder": "Sélectionner le dossier",
|
||||
"supported_providers": "fournisseurs pris en charge",
|
||||
"title": "Outils de code",
|
||||
"update_options": "Options de mise à jour",
|
||||
"working_directory": "répertoire de travail"
|
||||
@ -1319,7 +1320,8 @@
|
||||
"delete": {
|
||||
"content": "La suppression du groupe de messages supprimera les questions des utilisateurs et toutes les réponses des assistants",
|
||||
"title": "Supprimer le groupe de messages"
|
||||
}
|
||||
},
|
||||
"retry_failed": "message d'erreur de nouvelle tentative"
|
||||
},
|
||||
"ignore": {
|
||||
"knowledge": {
|
||||
@ -1550,6 +1552,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": {
|
||||
@ -1619,9 +1622,13 @@
|
||||
"new_folder": "Nouveau dossier",
|
||||
"new_note": "Nouvelle note",
|
||||
"no_content_to_copy": "Aucun contenu à copier",
|
||||
"no_file_selected": "Veuillez sélectionner le fichier à télécharger",
|
||||
"only_markdown": "uniquement le format Markdown est pris en charge",
|
||||
"only_one_file_allowed": "On ne peut télécharger qu'un seul fichier",
|
||||
"open_folder": "ouvrir le dossier externe",
|
||||
"open_outside": "Ouvrir depuis l'extérieur",
|
||||
"rename": "renommer",
|
||||
"rename_changed": "En raison de la politique de sécurité, le nom du fichier a été changé de {{original}} à {{final}}",
|
||||
"save": "sauvegarder dans les notes",
|
||||
"settings": {
|
||||
"data": {
|
||||
@ -3343,6 +3350,8 @@
|
||||
"label": "Déclencheur de popover de la grille"
|
||||
},
|
||||
"input": {
|
||||
"confirm_delete_message": "Confirmer avant de supprimer le message",
|
||||
"confirm_regenerate_message": "Confirmer avant de régénérer le message",
|
||||
"enable_quick_triggers": "Activer les menus rapides avec '/' et '@'",
|
||||
"paste_long_text_as_file": "Coller le texte long sous forme de fichier",
|
||||
"paste_long_text_threshold": "Seuil de longueur de texte",
|
||||
|
||||
@ -677,6 +677,7 @@
|
||||
"model_placeholder": "Selecione o modelo a ser utilizado",
|
||||
"model_required": "Selecione o modelo",
|
||||
"select_folder": "Selecionar pasta",
|
||||
"supported_providers": "Provedores de serviço suportados",
|
||||
"title": "Ferramenta de código",
|
||||
"update_options": "Opções de atualização",
|
||||
"working_directory": "diretório de trabalho"
|
||||
@ -1319,7 +1320,8 @@
|
||||
"delete": {
|
||||
"content": "Excluir mensagens de grupo removerá as perguntas dos usuários e todas as respostas do assistente",
|
||||
"title": "Excluir mensagens de grupo"
|
||||
}
|
||||
},
|
||||
"retry_failed": "Repetir mensagem com erro"
|
||||
},
|
||||
"ignore": {
|
||||
"knowledge": {
|
||||
@ -1550,6 +1552,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": {
|
||||
@ -1619,9 +1622,13 @@
|
||||
"new_folder": "Nova pasta",
|
||||
"new_note": "Nova nota",
|
||||
"no_content_to_copy": "Não há conteúdo para copiar",
|
||||
"no_file_selected": "Selecione o arquivo a ser enviado",
|
||||
"only_markdown": "Apenas o formato Markdown é suportado",
|
||||
"only_one_file_allowed": "só é possível enviar um arquivo",
|
||||
"open_folder": "Abrir pasta externa",
|
||||
"open_outside": "Abrir externamente",
|
||||
"rename": "renomear",
|
||||
"rename_changed": "Devido às políticas de segurança, o nome do arquivo foi alterado de {{original}} para {{final}}",
|
||||
"save": "salvar em notas",
|
||||
"settings": {
|
||||
"data": {
|
||||
@ -3343,6 +3350,8 @@
|
||||
"label": "Disparador de detalhes da grade"
|
||||
},
|
||||
"input": {
|
||||
"confirm_delete_message": "confirmar antes de excluir a mensagem",
|
||||
"confirm_regenerate_message": "Confirmar antes de regenerar a mensagem",
|
||||
"enable_quick_triggers": "Ativar menu rápido com '/' e '@'",
|
||||
"paste_long_text_as_file": "Colar texto longo como arquivo",
|
||||
"paste_long_text_threshold": "Limite de texto longo",
|
||||
|
||||
@ -139,7 +139,7 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
|
||||
} as Partial<Components>
|
||||
}, [block.id])
|
||||
|
||||
if (messageContent.includes('<style>')) {
|
||||
if (/<style\b[^>]*>/i.test(messageContent)) {
|
||||
components.style = MarkdownShadowDOMRenderer as any
|
||||
}
|
||||
|
||||
|
||||
@ -42,7 +42,19 @@ import {
|
||||
} from '@renderer/utils/messageUtils/find'
|
||||
import { Dropdown, Popconfirm, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { AtSign, Check, FilePenLine, Languages, ListChecks, Menu, Save, Split, ThumbsUp, Upload } from 'lucide-react'
|
||||
import {
|
||||
AtSign,
|
||||
Check,
|
||||
FilePenLine,
|
||||
Languages,
|
||||
ListChecks,
|
||||
Menu,
|
||||
NotebookPen,
|
||||
Save,
|
||||
Split,
|
||||
ThumbsUp,
|
||||
Upload
|
||||
} from 'lucide-react'
|
||||
import { FC, memo, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
@ -255,15 +267,6 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
onClick: () => {
|
||||
SaveToKnowledgePopup.showForMessage(message)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('notes.save'),
|
||||
key: 'clipboard',
|
||||
onClick: async () => {
|
||||
const title = await getMessageTitle(message)
|
||||
const markdown = messageToMarkdown(message)
|
||||
exportMessageToNotes(title, markdown, notesPath)
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -382,7 +385,6 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
toggleMultiSelectMode,
|
||||
message,
|
||||
mainTextContent,
|
||||
notesPath,
|
||||
messageContainerRef,
|
||||
topic.name
|
||||
]
|
||||
@ -620,6 +622,21 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isAssistantMessage && (
|
||||
<Tooltip title={t('notes.save')} mouseEnterDelay={0.8}>
|
||||
<ActionButton
|
||||
className="message-action-button"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
const title = await getMessageTitle(message)
|
||||
const markdown = messageToMarkdown(message)
|
||||
exportMessageToNotes(title, markdown, notesPath)
|
||||
}}
|
||||
$softHoverBg={softHoverBg}>
|
||||
<NotebookPen size={15} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{confirmDeleteMessage ? (
|
||||
<Popconfirm
|
||||
title={t('message.message.delete.content')}
|
||||
|
||||
@ -2,6 +2,7 @@ import EditableNumber from '@renderer/components/EditableNumber'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import Selector from '@renderer/components/Selector'
|
||||
import { HelpTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@renderer/config/constant'
|
||||
import { isOpenAIModel } from '@renderer/config/models'
|
||||
import { UNKNOWN } from '@renderer/config/translate'
|
||||
@ -48,8 +49,8 @@ import {
|
||||
import { Assistant, AssistantSettings, CodeStyleVarious, MathEngine, ThemeMode } from '@renderer/types'
|
||||
import { modalConfirm } from '@renderer/utils'
|
||||
import { getSendMessageShortcutLabel } from '@renderer/utils/input'
|
||||
import { Button, Col, InputNumber, Row, Slider, Switch, Tooltip } from 'antd'
|
||||
import { CircleHelp, Settings2 } from 'lucide-react'
|
||||
import { Button, Col, InputNumber, Row, Slider, Switch } from 'antd'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@ -193,10 +194,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
}>
|
||||
<SettingGroup style={{ marginTop: 5 }}>
|
||||
<Row align="middle">
|
||||
<SettingRowTitleSmall>{t('chat.settings.temperature.label')}</SettingRowTitleSmall>
|
||||
<Tooltip title={t('chat.settings.temperature.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.temperature.label')}
|
||||
<HelpTooltip title={t('chat.settings.temperature.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
style={{ marginLeft: 'auto' }}
|
||||
@ -224,10 +225,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingDivider />
|
||||
)}
|
||||
<Row align="middle">
|
||||
<SettingRowTitleSmall>{t('chat.settings.context_count.label')}</SettingRowTitleSmall>
|
||||
<Tooltip title={t('chat.settings.context_count.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.context_count.label')}
|
||||
<HelpTooltip title={t('chat.settings.context_count.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
</Row>
|
||||
<Row align="middle" gutter={10}>
|
||||
<Col span={23}>
|
||||
@ -256,10 +257,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<Row align="middle">
|
||||
<SettingRowTitleSmall>{t('chat.settings.max_tokens.label')}</SettingRowTitleSmall>
|
||||
<Tooltip title={t('chat.settings.max_tokens.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.max_tokens.label')}
|
||||
<HelpTooltip title={t('chat.settings.max_tokens.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
</Row>
|
||||
<Switch
|
||||
size="small"
|
||||
@ -327,9 +328,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.thought_auto_collapse.label')}
|
||||
<Tooltip title={t('chat.settings.thought_auto_collapse.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
<HelpTooltip title={t('chat.settings.thought_auto_collapse.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
@ -426,10 +425,8 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>
|
||||
{t('settings.math.single_dollar.label')}{' '}
|
||||
<Tooltip title={t('settings.math.single_dollar.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
{t('settings.math.single_dollar.label')}
|
||||
<HelpTooltip title={t('settings.math.single_dollar.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
@ -457,9 +454,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.code_execution.title')}
|
||||
<Tooltip title={t('chat.settings.code_execution.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
<HelpTooltip title={t('chat.settings.code_execution.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
@ -473,9 +468,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingRow style={{ paddingLeft: 8 }}>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.code_execution.timeout_minutes.label')}
|
||||
<Tooltip title={t('chat.settings.code_execution.timeout_minutes.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
<HelpTooltip title={t('chat.settings.code_execution.timeout_minutes.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
<EditableNumber
|
||||
size="small"
|
||||
@ -563,7 +556,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_image_tools')}</SettingRowTitleSmall>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.code_image_tools.label')}
|
||||
<HelpTooltip title={t('chat.settings.code_image_tools.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeImageTools}
|
||||
@ -713,6 +709,7 @@ const Container = styled(Scrollbar)`
|
||||
|
||||
const SettingRowTitleSmall = styled(SettingRowTitle)`
|
||||
font-size: 13px;
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const SettingGroup = styled.div<{ theme?: ThemeMode }>`
|
||||
|
||||
@ -37,6 +37,7 @@ import {
|
||||
FolderOpen,
|
||||
HelpCircle,
|
||||
MenuIcon,
|
||||
NotebookPen,
|
||||
PackagePlus,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
@ -276,6 +277,14 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
||||
onPinTopic(topic)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('notes.save'),
|
||||
key: 'notes',
|
||||
icon: <NotebookPen size={14} />,
|
||||
onClick: async () => {
|
||||
exportTopicToNotes(topic, notesPath)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.clear.title'),
|
||||
key: 'clear-messages',
|
||||
@ -345,13 +354,6 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
||||
window.message.error(t('chat.save.topic.knowledge.error.save_failed'))
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('notes.save'),
|
||||
key: 'notes',
|
||||
onClick: async () => {
|
||||
exportTopicToNotes(topic, notesPath)
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -4,8 +4,9 @@ import { isLocalAi } from '@renderer/config/env'
|
||||
import { isEmbeddingModel, isRerankModel, isWebSearchModel } from '@renderer/config/models'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { Assistant, Model } from '@renderer/types'
|
||||
import { Button } from 'antd'
|
||||
import { Button, Tag } from 'antd'
|
||||
import { ChevronsUpDown } from 'lucide-react'
|
||||
import { FC, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -19,6 +20,7 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
|
||||
const { model, updateAssistant } = useAssistant(assistant.id)
|
||||
const { t } = useTranslation()
|
||||
const timerRef = useRef<NodeJS.Timeout>(undefined)
|
||||
const provider = useAppSelector((state) => state.llm.providers.find((p) => p.id === model?.provider))
|
||||
|
||||
const modelFilter = (model: Model) => !isEmbeddingModel(model) && !isRerankModel(model)
|
||||
|
||||
@ -60,6 +62,7 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
|
||||
</ModelName>
|
||||
</ButtonContent>
|
||||
<ChevronsUpDown size={14} color="var(--color-icon)" />
|
||||
{!provider && <Tag color="error">{t('models.invalid_model')}</Tag>}
|
||||
</DropdownButton>
|
||||
)
|
||||
}
|
||||
|
||||
@ -25,8 +25,8 @@ const mocks = vi.hoisted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@renderer/components/InfoTooltip', () => ({
|
||||
default: ({ title }: { title: string }) => <div>{mocks.i18n.t(title)}</div>
|
||||
vi.mock('@renderer/components/TooltipIcons', () => ({
|
||||
InfoTooltip: ({ title }: { title: string }) => <div>{mocks.i18n.t(title)}</div>
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
|
||||
@ -31,8 +31,8 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
// Mock InfoTooltip component
|
||||
vi.mock('@renderer/components/InfoTooltip', () => ({
|
||||
default: ({ title, placement }: { title: string; placement: string }) => (
|
||||
vi.mock('@renderer/components/TooltipIcons', () => ({
|
||||
InfoTooltip: ({ title, placement }: { title: string; placement: string }) => (
|
||||
<span data-testid="info-tooltip" title={title} data-placement={placement}>
|
||||
ℹ️
|
||||
</span>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { KnowledgeBase } from '@renderer/types'
|
||||
import { Alert, InputNumber } from 'antd'
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import InputEmbeddingDimension from '@renderer/components/InputEmbeddingDimension'
|
||||
import ModelSelector from '@renderer/components/ModelSelector'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT } from '@renderer/config/constant'
|
||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import Ellipsis from '@renderer/components/Ellipsis'
|
||||
import { useFiles } from '@renderer/hooks/useFiles'
|
||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import StatusIcon from '@renderer/pages/knowledge/components/StatusIcon'
|
||||
@ -48,6 +49,7 @@ const getDisplayTime = (item: KnowledgeItem) => {
|
||||
const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap, preprocessMap }) => {
|
||||
const { t } = useTranslation()
|
||||
const [windowHeight, setWindowHeight] = useState(window.innerHeight)
|
||||
const { onSelectFile, selecting } = useFiles({ extensions: fileTypes })
|
||||
|
||||
const { base, fileItems, addFiles, refreshItem, removeItem, getProcessingStatus } = useKnowledge(
|
||||
selectedBase.id || ''
|
||||
@ -71,19 +73,12 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
|
||||
return null
|
||||
}
|
||||
|
||||
const handleAddFile = () => {
|
||||
if (disabled) {
|
||||
const handleAddFile = async () => {
|
||||
if (disabled || selecting) {
|
||||
return
|
||||
}
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.multiple = true
|
||||
input.accept = fileTypes.join(',')
|
||||
input.onchange = (e) => {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
files && handleDrop(Array.from(files))
|
||||
}
|
||||
input.click()
|
||||
const selectedFiles = await onSelectFile({ multipleSelections: true })
|
||||
processFiles(selectedFiles)
|
||||
}
|
||||
|
||||
const handleDrop = async (files: File[]) => {
|
||||
@ -118,8 +113,14 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
|
||||
}
|
||||
})
|
||||
.filter(({ ext }) => fileTypes.includes(ext))
|
||||
const uploadedFiles = await FileManager.uploadFiles(_files)
|
||||
logger.debug('uploadedFiles', uploadedFiles)
|
||||
processFiles(_files)
|
||||
}
|
||||
}
|
||||
|
||||
const processFiles = async (files: FileMetadata[]) => {
|
||||
logger.debug('processFiles', files)
|
||||
if (files.length > 0) {
|
||||
const uploadedFiles = await FileManager.uploadFiles(files)
|
||||
addFiles(uploadedFiles)
|
||||
}
|
||||
}
|
||||
@ -150,16 +151,23 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
|
||||
</ItemHeader>
|
||||
|
||||
<ItemFlexColumn>
|
||||
<Dragger
|
||||
showUploadList={false}
|
||||
customRequest={({ file }) => handleDrop([file as File])}
|
||||
multiple={true}
|
||||
accept={fileTypes.join(',')}>
|
||||
<p className="ant-upload-text">{t('knowledge.drag_file')}</p>
|
||||
<p className="ant-upload-hint">
|
||||
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
|
||||
</p>
|
||||
</Dragger>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddFile()
|
||||
}}>
|
||||
<Dragger
|
||||
showUploadList={false}
|
||||
customRequest={({ file }) => handleDrop([file as File])}
|
||||
multiple={true}
|
||||
accept={fileTypes.join(',')}
|
||||
openFileDialogOnClick={false}>
|
||||
<p className="ant-upload-text">{t('knowledge.drag_file')}</p>
|
||||
<p className="ant-upload-hint">
|
||||
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
|
||||
</p>
|
||||
</Dragger>
|
||||
</div>
|
||||
{fileItems.length === 0 ? (
|
||||
<KnowledgeEmptyView />
|
||||
) : (
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { loggerService } from '@logger'
|
||||
import AiProvider from '@renderer/aiCore'
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import InputEmbeddingDimension from '@renderer/components/InputEmbeddingDimension'
|
||||
import ModelSelector from '@renderer/components/ModelSelector'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import { useModel } from '@renderer/hooks/useModel'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
|
||||
@ -3,7 +3,7 @@ import { NavbarCenter, NavbarHeader, NavbarRight } from '@renderer/components/ap
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useStore'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||
import { findNodeInTree } from '@renderer/services/NotesTreeService'
|
||||
import { Breadcrumb, BreadcrumbProps, Dropdown, Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
|
||||
@ -2,7 +2,7 @@ import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useStore'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||
import { Tooltip } from 'antd'
|
||||
import { PanelLeftClose, PanelRightClose } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
@ -3,7 +3,7 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||
import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hooks/useNotesQuery'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||
import {
|
||||
createFolder,
|
||||
createNote,
|
||||
@ -20,6 +20,7 @@ import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType }
|
||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||
import { FileChangeEvent } from '@shared/config/types'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { debounce } from 'lodash'
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -34,7 +35,7 @@ const logger = loggerService.withContext('NotesPage')
|
||||
const NotesPage: FC = () => {
|
||||
const editorRef = useRef<RichEditorRef>(null)
|
||||
const { t } = useTranslation()
|
||||
const { showWorkspace } = useSettings()
|
||||
const { showWorkspace } = useShowWorkspace()
|
||||
const dispatch = useAppDispatch()
|
||||
const activeFilePath = useAppSelector(selectActiveFilePath)
|
||||
const sortType = useAppSelector(selectSortType)
|
||||
@ -113,8 +114,7 @@ const NotesPage: FC = () => {
|
||||
lastContentRef.current = newMarkdown
|
||||
lastFilePathRef.current = activeFilePath
|
||||
// 捕获当前文件路径,避免在防抖执行时文件路径已改变的竞态条件
|
||||
const currentFilePath = activeFilePath
|
||||
debouncedSave(newMarkdown, currentFilePath)
|
||||
debouncedSave(newMarkdown, activeFilePath)
|
||||
},
|
||||
[debouncedSave, activeFilePath]
|
||||
)
|
||||
@ -593,22 +593,31 @@ const NotesPage: FC = () => {
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('notes.title')}</NavbarCenter>
|
||||
</Navbar>
|
||||
<ContentContainer id="content-container">
|
||||
{showWorkspace && (
|
||||
<NotesSidebar
|
||||
notesTree={notesTree}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelectNode={handleSelectNode}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
onCreateNote={handleCreateNote}
|
||||
onDeleteNode={handleDeleteNode}
|
||||
onRenameNode={handleRenameNode}
|
||||
onToggleExpanded={handleToggleExpanded}
|
||||
onToggleStar={handleToggleStar}
|
||||
onMoveNode={handleMoveNode}
|
||||
onSortNodes={handleSortNodes}
|
||||
onUploadFiles={handleUploadFiles}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence initial={false}>
|
||||
{showWorkspace && (
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 250, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
style={{ overflow: 'hidden' }}>
|
||||
<NotesSidebar
|
||||
notesTree={notesTree}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelectNode={handleSelectNode}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
onCreateNote={handleCreateNote}
|
||||
onDeleteNode={handleDeleteNode}
|
||||
onRenameNode={handleRenameNode}
|
||||
onToggleExpanded={handleToggleExpanded}
|
||||
onToggleStar={handleToggleStar}
|
||||
onMoveNode={handleMoveNode}
|
||||
onSortNodes={handleSortNodes}
|
||||
onUploadFiles={handleUploadFiles}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<EditorWrapper>
|
||||
<HeaderNavbar notesTree={notesTree} getCurrentNoteContent={getCurrentNoteContent} />
|
||||
<NotesEditor
|
||||
|
||||
@ -303,6 +303,14 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
onClick: () => {
|
||||
handleStartEdit(node)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('notes.open_outside'),
|
||||
key: 'open_outside',
|
||||
icon: <FolderOpen size={14} />,
|
||||
onClick: () => {
|
||||
window.api.openPath(node.externalPath)
|
||||
}
|
||||
}
|
||||
]
|
||||
if (node.type !== 'folder') {
|
||||
@ -520,6 +528,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
|
||||
const SidebarContainer = styled.div`
|
||||
width: 250px;
|
||||
min-width: 250px;
|
||||
height: 100vh;
|
||||
background-color: var(--color-background);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { CheckOutlined } from '@ant-design/icons'
|
||||
import { NotesSortType } from '@renderer/types/note'
|
||||
import { Dropdown, Input, MenuProps, Tooltip } from 'antd'
|
||||
import { ArrowLeft, ArrowUpNarrowWide, FilePlus, FolderPlus, Search, Star } from 'lucide-react'
|
||||
import { ArrowLeft, ArrowUpNarrowWide, FilePlus2, FolderPlus, Search, Star } from 'lucide-react'
|
||||
import { FC, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@ -77,7 +77,7 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
|
||||
|
||||
<Tooltip title={t('notes.new_note')} mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onCreateNote}>
|
||||
<FilePlus size={18} />
|
||||
<FilePlus2 size={18} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ import 'emoji-picker-element'
|
||||
|
||||
import { CloseCircleFilled } from '@ant-design/icons'
|
||||
import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import CodeViewer from '@renderer/components/CodeViewer'
|
||||
import EmojiPicker from '@renderer/components/EmojiPicker'
|
||||
import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout'
|
||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||
@ -14,6 +13,7 @@ import { Button, Input, Popover } from 'antd'
|
||||
import { Edit, HelpCircle, Save } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingDivider } from '..'
|
||||
@ -122,7 +122,9 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
|
||||
<TextAreaContainer>
|
||||
<RichEditorContainer>
|
||||
{showPreview ? (
|
||||
<CodeViewer children={processedPrompt} language="markdown" expanded={true} height="100%" />
|
||||
<MarkdownContainer>
|
||||
<ReactMarkdown>{processedPrompt || prompt}</ReactMarkdown>
|
||||
</MarkdownContainer>
|
||||
) : (
|
||||
<CodeEditor
|
||||
value={prompt}
|
||||
@ -214,4 +216,10 @@ const RichEditorContainer = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const MarkdownContainer = styled.div.attrs({ className: 'markdown' })`
|
||||
height: 100%;
|
||||
padding: 0.5em;
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
export default AssistantPromptSettings
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// import { loggerService } from '@logger'
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import { SuccessTag } from '@renderer/components/Tags/SuccessTag'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { isMac, isWin } from '@renderer/config/constant'
|
||||
import { useOcrProvider } from '@renderer/hooks/useOcrProvider'
|
||||
import useTranslate from '@renderer/hooks/useTranslate'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// import { loggerService } from '@logger'
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import CustomTag from '@renderer/components/Tags/CustomTag'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { TESSERACT_LANG_MAP } from '@renderer/config/ocr'
|
||||
import { useOcrProvider } from '@renderer/hooks/useOcrProvider'
|
||||
import useTranslate from '@renderer/hooks/useTranslate'
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import Selector from '@renderer/components/Selector'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useEnableDeveloperMode, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
|
||||
@ -55,7 +55,7 @@ const McpServersList: FC = () => {
|
||||
originalList: mcpServers,
|
||||
filteredList: filteredMcpServers,
|
||||
onUpdate: updateMcpServers,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { RedoOutlined } from '@ant-design/icons'
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import ModelSelector from '@renderer/components/ModelSelector'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
|
||||
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { Flex, Switch } from 'antd'
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
VisionTag,
|
||||
WebSearchTag
|
||||
} from '@renderer/components/Tags/Model'
|
||||
import WarnTooltip from '@renderer/components/WarnTooltip'
|
||||
import { WarnTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { endpointTypeOptions } from '@renderer/config/endpointTypes'
|
||||
import {
|
||||
isEmbeddingModel,
|
||||
|
||||
@ -321,7 +321,7 @@ const ProviderList: FC = () => {
|
||||
originalList: providers,
|
||||
filteredList: filteredProviders,
|
||||
onUpdate: updateProviders,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import EmojiPicker from '@renderer/components/EmojiPicker'
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import useTranslate from '@renderer/hooks/useTranslate'
|
||||
import { addCustomLanguage, updateCustomLanguage } from '@renderer/services/TranslateService'
|
||||
import { CustomTranslateLanguage } from '@renderer/types'
|
||||
|
||||
@ -134,8 +134,15 @@ export function getAssistantProvider(assistant: Assistant): Provider {
|
||||
|
||||
export function getProviderByModel(model?: Model): Provider {
|
||||
const providers = store.getState().llm.providers
|
||||
const providerId = model ? model.provider : getDefaultProvider().id
|
||||
return providers.find((p) => p.id === providerId) as Provider
|
||||
const provider = providers.find((p) => p.id === model?.provider)
|
||||
|
||||
if (!provider) {
|
||||
const defaultProvider = providers.find((p) => p.id === getDefaultModel()?.provider)
|
||||
const cherryinProvider = providers.find((p) => p.id === 'cherryin')
|
||||
return defaultProvider || cherryinProvider || providers[0]
|
||||
}
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
export function getProviderByModelId(modelId?: string) {
|
||||
|
||||
@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 145,
|
||||
version: 146,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -2348,6 +2348,26 @@ const migrateConfig = {
|
||||
logger.error('migrate 145 error', error as Error)
|
||||
return state
|
||||
}
|
||||
},
|
||||
'146': (state: RootState) => {
|
||||
try {
|
||||
// Migrate showWorkspace from settings to note store
|
||||
if (state.settings && state.note) {
|
||||
const showWorkspaceValue = (state.settings as any)?.showWorkspace
|
||||
if (showWorkspaceValue !== undefined) {
|
||||
state.note.settings.showWorkspace = showWorkspaceValue
|
||||
// Remove from settings
|
||||
delete (state.settings as any).showWorkspace
|
||||
} else if (state.note.settings.showWorkspace === undefined) {
|
||||
// Set default value if not exists
|
||||
state.note.settings.showWorkspace = true
|
||||
}
|
||||
}
|
||||
return state
|
||||
} catch (error) {
|
||||
logger.error('migrate 146 error', error as Error)
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ export interface NotesSettings {
|
||||
defaultViewMode: 'edit' | 'read'
|
||||
defaultEditMode: Omit<EditorView, 'read'>
|
||||
showTabStatus: boolean
|
||||
showWorkspace: boolean
|
||||
}
|
||||
|
||||
export interface NoteState {
|
||||
@ -27,7 +28,8 @@ export const initialState: NoteState = {
|
||||
fontFamily: 'default',
|
||||
defaultViewMode: 'edit',
|
||||
defaultEditMode: 'preview',
|
||||
showTabStatus: true
|
||||
showTabStatus: true,
|
||||
showWorkspace: true
|
||||
},
|
||||
notesPath: '',
|
||||
sortType: 'sort_a2z'
|
||||
|
||||
@ -215,8 +215,6 @@ export interface SettingsState {
|
||||
// API Server
|
||||
apiServer: ApiServerConfig
|
||||
showMessageOutline: boolean
|
||||
// Notes Related
|
||||
showWorkspace: boolean
|
||||
}
|
||||
|
||||
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
|
||||
@ -409,9 +407,7 @@ export const initialState: SettingsState = {
|
||||
port: 23333,
|
||||
apiKey: `cs-sk-${uuid()}`
|
||||
},
|
||||
showMessageOutline: false,
|
||||
// Notes Related
|
||||
showWorkspace: true
|
||||
showMessageOutline: false
|
||||
}
|
||||
|
||||
const settingsSlice = createSlice({
|
||||
@ -846,12 +842,6 @@ const settingsSlice = createSlice({
|
||||
},
|
||||
setShowMessageOutline: (state, action: PayloadAction<boolean>) => {
|
||||
state.showMessageOutline = action.payload
|
||||
},
|
||||
setShowWorkspace: (state, action: PayloadAction<boolean>) => {
|
||||
state.showWorkspace = action.payload
|
||||
},
|
||||
toggleShowWorkspace: (state) => {
|
||||
state.showWorkspace = !state.showWorkspace
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -982,9 +972,7 @@ export const {
|
||||
// API Server actions
|
||||
setApiServerEnabled,
|
||||
setApiServerPort,
|
||||
setApiServerApiKey,
|
||||
setShowWorkspace,
|
||||
toggleShowWorkspace
|
||||
setApiServerApiKey
|
||||
} = settingsSlice.actions
|
||||
|
||||
export default settingsSlice.reducer
|
||||
|
||||
@ -103,7 +103,7 @@ export const isBuiltinOcrProvider = (p: OcrProvider): p is BuiltinOcrProvider =>
|
||||
return isBuiltinOcrProviderId(p.id)
|
||||
}
|
||||
|
||||
// Not sure compatiable api endpoint exists. May not support custom ocr provider
|
||||
// Not sure compatible api endpoint exists. May not support custom ocr provider
|
||||
export type CustomOcrProvider = OcrProvider & {
|
||||
id: Exclude<string, BuiltinOcrProviderId>
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ describe('markdownConverter', () => {
|
||||
|
||||
it('should convert task list HTML back to Markdown with label', () => {
|
||||
const html =
|
||||
'<ul data-type="taskList" class="task-list"><li data-type="taskItem" class="task-list-item" data-checked="false"><label><input type="checkbox"> abcd</label></li><li data-type="taskItem" class="task-list-item" data-checked="true"><label><input type="checkbox" checked> efgh</lable></li></ul>'
|
||||
'<ul data-type="taskList" class="task-list"><li data-type="taskItem" class="task-list-item" data-checked="false"><label><input type="checkbox"> abcd</label></li><li data-type="taskItem" class="task-list-item" data-checked="true"><label><input type="checkbox" checked> efgh</label></li></ul>'
|
||||
const result = htmlToMarkdown(html)
|
||||
expect(result).toBe('- [ ] abcd\n\n- [x] efgh')
|
||||
})
|
||||
@ -361,7 +361,7 @@ describe('markdownConverter', () => {
|
||||
})
|
||||
|
||||
describe('markdown image', () => {
|
||||
it('should convert markdown iamge to HTML img tag', () => {
|
||||
it('should convert markdown image to HTML img tag', () => {
|
||||
const markdown = ''
|
||||
const result = markdownToHtml(markdown)
|
||||
expect(result).toBe('<p><img src="train.jpg" alt="foo" /></p>\n')
|
||||
|
||||
@ -182,59 +182,200 @@ export async function captureScrollableIframe(
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>
|
||||
): Promise<HTMLCanvasElement | undefined> {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe) return Promise.resolve(undefined)
|
||||
if (!iframe?.contentDocument?.defaultView) return undefined
|
||||
|
||||
const doc = iframe.contentDocument
|
||||
const win = doc?.defaultView
|
||||
if (!doc || !win) return Promise.resolve(undefined)
|
||||
const win = iframe.contentWindow!
|
||||
|
||||
// 等待两帧渲染稳定
|
||||
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())))
|
||||
// 禁用动画以确保捕获静态状态
|
||||
const disableAnimations = () => {
|
||||
const style = doc.createElement('style')
|
||||
style.textContent = `*, *::before, *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
// transform: none !important;
|
||||
}`
|
||||
doc.head.appendChild(style)
|
||||
return style
|
||||
}
|
||||
|
||||
// 触发懒加载资源尽快加载
|
||||
doc.querySelectorAll('img[loading="lazy"]').forEach((img) => img.setAttribute('loading', 'eager'))
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
// 内联字体以避免跨域问题
|
||||
const inlineFonts = async () => {
|
||||
const fontFaceRegex = /@font-face[\s\S]*?\}/g
|
||||
const fontUrlRegex = /url\((['"]?)([^)"']+)\1\)/g
|
||||
const fontExtRegex = /\.(woff2?|ttf|otf)(\?|#|$)/i
|
||||
|
||||
const de = doc.documentElement
|
||||
const b = doc.body
|
||||
const fetchAsDataUrl = async (url: string): Promise<string> => {
|
||||
try {
|
||||
const res = await fetch(url, { mode: 'cors', credentials: 'omit' })
|
||||
if (!res.ok) return url
|
||||
const blob = await res.blob()
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => resolve(reader.result as string)
|
||||
reader.onerror = () => resolve(url)
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// 计算完整尺寸
|
||||
const totalWidth = Math.max(b.scrollWidth, de.scrollWidth, b.clientWidth, de.clientWidth)
|
||||
const totalHeight = Math.max(b.scrollHeight, de.scrollHeight, b.clientHeight, de.clientHeight)
|
||||
const processCss = async (cssText: string, baseUrl: string): Promise<string[]> => {
|
||||
const fontBlocks: string[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
logger.verbose('The iframe to be captured has size:', { totalWidth, totalHeight })
|
||||
while ((match = fontFaceRegex.exec(cssText)) !== null) {
|
||||
let block = match[0]
|
||||
const fontUrls: Array<[string, string]> = []
|
||||
|
||||
// 按比例缩放以不超过上限
|
||||
const MAX = 32767
|
||||
const maxSide = Math.max(totalWidth, totalHeight)
|
||||
const scale = maxSide > MAX ? MAX / maxSide : 1
|
||||
const pixelRatio = (win.devicePixelRatio || 1) * scale
|
||||
let urlMatch: RegExpExecArray | null
|
||||
fontUrlRegex.lastIndex = 0
|
||||
while ((urlMatch = fontUrlRegex.exec(block)) !== null) {
|
||||
const url = urlMatch[2]
|
||||
if (!url.startsWith('data:') && fontExtRegex.test(url)) {
|
||||
try {
|
||||
const absoluteUrl = new URL(url, baseUrl).href
|
||||
fontUrls.push([urlMatch[0], absoluteUrl])
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bg = win.getComputedStyle(b).backgroundColor || '#ffffff'
|
||||
const fg = win.getComputedStyle(b).color || '#000000'
|
||||
// 并行处理所有字体URL
|
||||
const dataUrls = await Promise.all(
|
||||
fontUrls.map(async ([original, url]) => {
|
||||
const dataUrl = await fetchAsDataUrl(url)
|
||||
return [original, `url(${dataUrl})`] as const
|
||||
})
|
||||
)
|
||||
|
||||
dataUrls.forEach(([original, replacement]) => {
|
||||
block = block.replace(original, replacement)
|
||||
})
|
||||
|
||||
fontBlocks.push(block)
|
||||
}
|
||||
|
||||
return fontBlocks
|
||||
}
|
||||
|
||||
const allFontBlocks: string[] = []
|
||||
|
||||
// 处理外部样式表
|
||||
const externalSheets = doc.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')
|
||||
await Promise.all(
|
||||
Array.from(externalSheets).map(async (link) => {
|
||||
if (!link.href) return
|
||||
try {
|
||||
const res = await fetch(link.href, { mode: 'cors', credentials: 'omit' })
|
||||
if (res.ok) {
|
||||
const cssText = await res.text()
|
||||
const blocks = await processCss(cssText, link.href)
|
||||
allFontBlocks.push(...blocks)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 处理内联样式
|
||||
const inlineStyles = doc.querySelectorAll('style')
|
||||
await Promise.all(
|
||||
Array.from(inlineStyles).map(async (style) => {
|
||||
const cssText = style.textContent || ''
|
||||
const blocks = await processCss(cssText, doc.baseURI)
|
||||
allFontBlocks.push(...blocks)
|
||||
})
|
||||
)
|
||||
|
||||
return allFontBlocks.join('\n')
|
||||
}
|
||||
|
||||
const animationStyle = disableAnimations()
|
||||
let injectedFontStyle: HTMLStyleElement | null = null
|
||||
|
||||
const ensureFontStyle = (css: string): HTMLStyleElement => {
|
||||
const EXISTING = doc.head.querySelector('style[data-cs-inline-fonts="true"]') as HTMLStyleElement | null
|
||||
if (EXISTING) {
|
||||
if (css && css.trim()) {
|
||||
EXISTING.textContent = `${EXISTING.textContent || ''}\n${css}`
|
||||
}
|
||||
return EXISTING
|
||||
}
|
||||
const style = doc.createElement('style')
|
||||
style.setAttribute('data-cs-inline-fonts', 'true')
|
||||
style.textContent = css
|
||||
doc.head.appendChild(style)
|
||||
return style
|
||||
}
|
||||
|
||||
try {
|
||||
const canvas = await htmlToImage.toCanvas(de, {
|
||||
backgroundColor: bg,
|
||||
// 等待渲染稳定
|
||||
await new Promise((r) => win.requestAnimationFrame(() => win.requestAnimationFrame(() => r(null))))
|
||||
|
||||
// 强制加载懒加载图片
|
||||
doc.querySelectorAll('img[loading="lazy"]').forEach((img) => img.setAttribute('loading', 'eager'))
|
||||
|
||||
// 获取字体CSS
|
||||
const fontEmbedCSS = await inlineFonts()
|
||||
|
||||
// 将字体 CSS 注入到 iframe 文档中,确保注册到 FontFaceSet
|
||||
if (fontEmbedCSS && fontEmbedCSS.trim().length > 0) {
|
||||
injectedFontStyle = ensureFontStyle(fontEmbedCSS)
|
||||
// 访问一次以避免被标记为未使用
|
||||
if (injectedFontStyle.parentNode == null) {
|
||||
doc.head.appendChild(injectedFontStyle)
|
||||
}
|
||||
}
|
||||
|
||||
// 等待字体就绪,避免序列化时回退到系统字体
|
||||
await Promise.race([
|
||||
(doc as any).fonts?.ready ?? Promise.resolve(),
|
||||
new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
])
|
||||
|
||||
// 计算尺寸
|
||||
const { documentElement: de, body: b } = doc
|
||||
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('Capturing iframe:', { totalWidth, totalHeight })
|
||||
|
||||
// 限制最大尺寸,按比例缩放
|
||||
const MAX_SIZE = 32767
|
||||
const scale = Math.min(1, MAX_SIZE / Math.max(totalWidth, totalHeight))
|
||||
const pixelRatio = (win.devicePixelRatio || 1) * scale
|
||||
|
||||
const styles = win.getComputedStyle(b)
|
||||
const backgroundColor = styles.backgroundColor || '#ffffff'
|
||||
const color = styles.color || '#000000'
|
||||
|
||||
return await htmlToImage.toCanvas(de, {
|
||||
fontEmbedCSS,
|
||||
backgroundColor,
|
||||
cacheBust: true,
|
||||
pixelRatio,
|
||||
skipAutoScale: true,
|
||||
width: Math.floor(totalWidth),
|
||||
height: Math.floor(totalHeight),
|
||||
style: {
|
||||
backgroundColor: bg,
|
||||
color: fg,
|
||||
backgroundColor,
|
||||
color,
|
||||
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)
|
||||
logger.error('Error capturing iframe:', error as Error)
|
||||
return undefined
|
||||
} finally {
|
||||
// 恢复动画
|
||||
animationStyle.remove()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user