Merge remote-tracking branch 'origin/main' into feat/aisdk-package

This commit is contained in:
MyPrototypeWhat 2025-09-03 19:00:42 +08:00
commit 5aa8f3901f
65 changed files with 665 additions and 255 deletions

View File

@ -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 // 内联所有动态导入,这是关键配置

View File

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

View File

@ -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": ""
},
{

View File

@ -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/DEBXDG 标准路径)
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

View File

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

View File

@ -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 的 basicSetupoptions 优先
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -56,6 +56,7 @@ const MermaidPreview = ({
document.body.removeChild(measureEl)
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[diagramId, mermaid, forceRenderKey]
)

View 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

View File

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

View File

@ -0,0 +1,3 @@
export { default as HelpTooltip } from './HelpTooltip'
export { default as InfoTooltip } from './InfoTooltip'
export { default as WarnTooltip } from './WarnTooltip'

View File

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

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

View File

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

View File

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

View File

@ -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": "メモに保存する",

View File

@ -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": "Сохранить в заметки",

View File

@ -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": "保存到笔记",

View File

@ -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": "儲存到筆記",

View File

@ -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": "Όριο μεγάλου κειμένου",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -55,7 +55,7 @@ const McpServersList: FC = () => {
originalList: mcpServers,
filteredList: filteredMcpServers,
onUpdate: updateMcpServers,
idKey: 'id'
itemKey: 'id'
})
const scrollRef = useRef<HTMLDivElement>(null)

View File

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

View File

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

View File

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

View File

@ -321,7 +321,7 @@ const ProviderList: FC = () => {
originalList: providers,
filteredList: filteredProviders,
onUpdate: updateProviders,
idKey: 'id'
itemKey: 'id'
})
const handleDragStart = useCallback(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = '![foo](train.jpg)'
const result = markdownToHtml(markdown)
expect(result).toBe('<p><img src="train.jpg" alt="foo" /></p>\n')

View File

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