From 72d0fea3a1aef2eeac90cca626af47a8af497fe5 Mon Sep 17 00:00:00 2001 From: one Date: Sat, 16 Aug 2025 23:19:47 +0800 Subject: [PATCH 01/42] refactor(SvgPreview,Markdown): make svg size adaptive (#9232) * refactor(Svg): make svg preview scalable * feat: make svg in markdown scalable * refactor: add measureElementSize * refactor: improve rehypeScalableSvg, add MarkdownSvgRenderer * fix: svg namespace * perf: improve namespace correction * refactor: rename makeSvgScalable to makeSvgSizeAdaptive * test: fix tests for renderSvgInShadowHost * refactor: improve MarkdownSvgRenderer re-render * feat: sanitize svg before rendering * feat: make MarkdownSvgRenderer clickable * test: fix * Revert "feat: make MarkdownSvgRenderer clickable" This reverts commit 73af8fbb8c58b7f3986606e5f249113d1cbb9fdf. * refactor: use context menu in MarkdownSvgRenderer * refactor: remove preserveAspectRatio from svg --- package.json | 1 + .../Preview/__tests__/utils.test.ts | 57 +++++++++----- src/renderer/src/components/Preview/utils.ts | 65 +++++++++++----- .../src/pages/home/Markdown/Markdown.tsx | 7 +- .../home/Markdown/MarkdownSvgRenderer.tsx | 76 ++++++++++++++++++ .../home/Markdown/__tests__/Markdown.test.tsx | 22 +++++- .../Markdown/plugins/rehypeScalableSvg.ts | 65 ++++++++++++++++ .../src/utils/__tests__/image.test.ts | 78 ++++++++++++++++++- src/renderer/src/utils/image.ts | 78 +++++++++++++++++++ yarn.lock | 3 +- 10 files changed, 408 insertions(+), 44 deletions(-) create mode 100644 src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx create mode 100644 src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts diff --git a/package.json b/package.json index a824984845..70ed89485c 100644 --- a/package.json +++ b/package.json @@ -179,6 +179,7 @@ "dexie-react-hooks": "^1.1.7", "diff": "^7.0.0", "docx": "^9.0.2", + "dompurify": "^3.2.6", "dotenv-cli": "^7.4.2", "electron": "37.2.3", "electron-builder": "26.0.15", diff --git a/src/renderer/src/components/Preview/__tests__/utils.test.ts b/src/renderer/src/components/Preview/__tests__/utils.test.ts index c9722c33d4..bfa67c1516 100644 --- a/src/renderer/src/components/Preview/__tests__/utils.test.ts +++ b/src/renderer/src/components/Preview/__tests__/utils.test.ts @@ -10,29 +10,39 @@ describe('renderSvgInShadowHost', () => { // Mock attachShadow Element.prototype.attachShadow = vi.fn().mockImplementation(function (this: HTMLElement) { - const shadowRoot = document.createElement('div') + // Check if a shadow root already exists to prevent re-creating it. + if (this.shadowRoot) { + return this.shadowRoot + } + + // Create a container that acts as the shadow root. + const shadowRootContainer = document.createElement('div') + shadowRootContainer.dataset.testid = 'shadow-root' + Object.defineProperty(this, 'shadowRoot', { - value: shadowRoot, + value: shadowRootContainer, writable: true, configurable: true }) - // Simple innerHTML copy for test verification - Object.defineProperty(shadowRoot, 'innerHTML', { - set(value) { - shadowRoot.textContent = value // A simplified mock + + // Mock essential methods like appendChild and innerHTML. + // JSDOM doesn't fully implement shadow DOM, so we simulate its behavior. + const originalInnerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML') + Object.defineProperty(shadowRootContainer, 'innerHTML', { + set(value: string) { + // Clear existing content and parse the new HTML. + originalInnerHTMLDescriptor?.set?.call(this, '') + const template = document.createElement('template') + template.innerHTML = value + shadowRootContainer.append(...Array.from(template.content.childNodes)) }, get() { - return shadowRoot.textContent || '' + return originalInnerHTMLDescriptor?.get?.call(this) ?? '' }, configurable: true }) - shadowRoot.appendChild = vi.fn((node: T): T => { - shadowRoot.append(node) - return node - }) - - return shadowRoot as unknown as ShadowRoot + return shadowRootContainer as unknown as ShadowRoot }) }) @@ -57,7 +67,7 @@ describe('renderSvgInShadowHost', () => { expect(Element.prototype.attachShadow).not.toHaveBeenCalled() // Verify it works with the existing shadow root - expect(existingShadowRoot.appendChild).toHaveBeenCalled() + expect(existingShadowRoot.innerHTML).toContain(' { @@ -71,20 +81,31 @@ describe('renderSvgInShadowHost', () => { expect(shadowRoot?.querySelector('rect')).not.toBeNull() }) + it('should add the xmlns attribute if it is missing', () => { + const svgWithoutXmlns = '' + renderSvgInShadowHost(svgWithoutXmlns, hostElement) + + const svgElement = hostElement.shadowRoot?.querySelector('svg') + expect(svgElement).not.toBeNull() + expect(svgElement?.getAttribute('xmlns')).toBe('http://www.w3.org/2000/svg') + }) + it('should throw an error if the host element is not available', () => { expect(() => renderSvgInShadowHost('', null as any)).toThrow( 'Host element for SVG rendering is not available.' ) }) - it('should throw an error for invalid SVG content', () => { - const invalidSvg = '' // Malformed - expect(() => renderSvgInShadowHost(invalidSvg, hostElement)).toThrow(/SVG parsing error/) + it('should not throw an error for malformed SVG content due to HTML parser fallback', () => { + const invalidSvg = '' // Malformed, but fixable by the browser's HTML parser + expect(() => renderSvgInShadowHost(invalidSvg, hostElement)).not.toThrow() + // Also, assert that it successfully rendered something. + expect(hostElement.shadowRoot?.querySelector('svg')).not.toBeNull() }) it('should throw an error for non-SVG content', () => { const nonSvg = '
this is not svg
' - expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow('Invalid SVG content') + expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow() }) it('should not throw an error for empty or whitespace content', () => { diff --git a/src/renderer/src/components/Preview/utils.ts b/src/renderer/src/components/Preview/utils.ts index db5ba3457b..e400062911 100644 --- a/src/renderer/src/components/Preview/utils.ts +++ b/src/renderer/src/components/Preview/utils.ts @@ -1,3 +1,6 @@ +import { makeSvgSizeAdaptive } from '@renderer/utils' +import DOMPurify from 'dompurify' + /** * Renders an SVG string inside a host element's Shadow DOM to ensure style encapsulation. * This function handles creating the shadow root, injecting base styles for the host, @@ -12,15 +15,22 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme throw new Error('Host element for SVG rendering is not available.') } + // Sanitize the SVG content + const sanitizedContent = DOMPurify.sanitize(svgContent, { + USE_PROFILES: { svg: true, svgFilters: true }, + RETURN_DOM_FRAGMENT: false, + RETURN_DOM: false + }) + const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' }) - // Base styles for the host element + // Base styles for the host element and the inner SVG const style = document.createElement('style') style.textContent = ` :host { padding: 1em; background-color: white; - overflow: auto; + overflow: hidden; /* Prevent scrollbars, as scaling is now handled */ border: 0.5px solid var(--color-code-background); border-radius: 8px; display: block; @@ -28,34 +38,51 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme width: 100%; height: 100%; } - svg { - max-width: 100%; - height: auto; - } ` - // Clear previous content and append new style and SVG + // Clear previous content and append new style shadowRoot.innerHTML = '' shadowRoot.appendChild(style) - // Parse and append the SVG using DOMParser to prevent script execution and check for errors - if (svgContent.trim() === '') { + if (sanitizedContent.trim() === '') { return } - const parser = new DOMParser() - const doc = parser.parseFromString(svgContent, 'image/svg+xml') + const parser = new DOMParser() + const doc = parser.parseFromString(sanitizedContent, 'image/svg+xml') const parserError = doc.querySelector('parsererror') - if (parserError) { - // Throw a specific error that can be caught by the calling component - throw new Error(`SVG parsing error: ${parserError.textContent || 'Unknown parsing error'}`) + let svgElement: Element = doc.documentElement + + // If parsing fails or the namespace is incorrect, fall back to the more lenient HTML parser. + if (parserError || svgElement.namespaceURI !== 'http://www.w3.org/2000/svg') { + const tempDiv = document.createElement('div') + tempDiv.innerHTML = sanitizedContent + const svgFromHtml = tempDiv.querySelector('svg') + + if (svgFromHtml) { + // Directly use the DOM node created by the HTML parser. + svgElement = svgFromHtml + // Ensure the xmlns attribute is present. + svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + } else { + // If both parsing methods fail, the SVG content is genuinely invalid. + if (parserError) { + throw new Error(`SVG parsing error: ${parserError.textContent || 'Unknown parsing error'}`) + } + throw new Error('Invalid SVG content: The provided string does not contain a valid SVG element.') + } } - const svgElement = doc.documentElement - if (svgElement && svgElement.nodeName.toLowerCase() === 'svg') { - shadowRoot.appendChild(svgElement.cloneNode(true)) - } else if (svgContent.trim() !== '') { - // Do not throw error for empty content + // Type guard + if (svgElement instanceof SVGSVGElement) { + // Standardize the SVG element for proper scaling + makeSvgSizeAdaptive(svgElement) + + // Append the SVG element to the shadow root + shadowRoot.appendChild(svgElement) + } else { + // This path is taken if the content is valid XML but not a valid SVG document + // (e.g., root element is not ), or if the fallback parser fails. throw new Error('Invalid SVG content: The provided string is not a valid SVG document.') } } diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 98a24b8735..47fc77df72 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -29,7 +29,9 @@ import { Pluggable } from 'unified' import CodeBlock from './CodeBlock' import Link from './Link' +import MarkdownSvgRenderer from './MarkdownSvgRenderer' import rehypeHeadingIds from './plugins/rehypeHeadingIds' +import rehypeScalableSvg from './plugins/rehypeScalableSvg' import remarkDisableConstructs from './plugins/remarkDisableConstructs' import Table from './Table' @@ -113,7 +115,7 @@ const Markdown: FC = ({ block, postProcess }) => { const rehypePlugins = useMemo(() => { const plugins: Pluggable[] = [] if (ALLOWED_ELEMENTS.test(messageContent)) { - plugins.push(rehypeRaw) + plugins.push(rehypeRaw, rehypeScalableSvg) } plugins.push([rehypeHeadingIds, { prefix: `heading-${block.id}` }]) if (mathEngine === 'KaTeX') { @@ -148,7 +150,8 @@ const Markdown: FC = ({ block, postProcess }) => { const hasImage = props?.node?.children?.some((child: any) => child.tagName === 'img') if (hasImage) return
return

- } + }, + svg: MarkdownSvgRenderer } as Partial }, [onSaveCodeBlock, block.id]) diff --git a/src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx b/src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx new file mode 100644 index 0000000000..1313b95a45 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx @@ -0,0 +1,76 @@ +import { ImagePreviewService } from '@renderer/services/ImagePreviewService' +import { makeSvgSizeAdaptive } from '@renderer/utils/image' +import { Dropdown } from 'antd' +import { Eye } from 'lucide-react' +import React, { FC, useCallback, useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' + +interface SvgProps extends React.SVGProps { + 'data-needs-measurement'?: 'true' +} + +/** + * A smart SVG renderer for Markdown content. + * + * This component handles two types of SVGs passed from `react-markdown`: + * + * 1. **Pre-processed SVGs**: Simple SVGs that were already handled by the + * `rehypeScalableSvg` plugin. These are rendered directly. + * + * 2. **SVGs needing measurement**: Complex SVGs are flagged with + * `data-needs-measurement`. This component performs a one-time DOM + * mutation upon mounting to make them scalable. To prevent React from + * reverting these changes during subsequent renders, it stops passing + * the original `width` and `height` props after the mutation is complete. + */ +const MarkdownSvgRenderer: FC = (props) => { + const { 'data-needs-measurement': needsMeasurement, ...restProps } = props + const svgRef = useRef(null) + const isMeasuredRef = useRef(false) + const { t } = useTranslation() + + useEffect(() => { + if (needsMeasurement && svgRef.current && !isMeasuredRef.current) { + // Directly mutate the DOM element to make it adaptive. + makeSvgSizeAdaptive(svgRef.current) + // Set flag to prevent re-measuring. This does not trigger a re-render. + isMeasuredRef.current = true + } + }, [needsMeasurement]) + + const onPreview = useCallback(() => { + if (!svgRef.current) return + ImagePreviewService.show(svgRef.current, { format: 'svg' }) + }, []) + + const contextMenuItems = useMemo( + () => [ + { + key: 'preview', + label: t('common.preview'), + icon: , + onClick: onPreview + } + ], + [onPreview, t] + ) + + // Create a mutable copy of props to potentially modify. + const finalProps = { ...restProps } + + // If the SVG has been measured and mutated, we prevent React from + // re-applying the original width and height attributes on subsequent renders. + // This preserves the changes made by `makeSvgSizeAdaptive`. + if (isMeasuredRef.current) { + delete finalProps.width + delete finalProps.height + } + + return ( + + + + ) +} + +export default MarkdownSvgRenderer diff --git a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx index b7e1ee8b52..b4d832f3fd 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx @@ -68,9 +68,9 @@ vi.mock('../CodeBlock', () => ({ ) })) -vi.mock('../ImagePreview', () => ({ +vi.mock('@renderer/components/ImageViewer', () => ({ __esModule: true, - default: (props: any) => + default: (props: any) => })) vi.mock('../Link', () => ({ @@ -94,12 +94,18 @@ vi.mock('../Table', () => ({ ) })) +vi.mock('../MarkdownSvgRenderer', () => ({ + __esModule: true, + default: ({ children }: any) =>

{children}
+})) + vi.mock('@renderer/components/MarkdownShadowDOMRenderer', () => ({ __esModule: true, default: ({ children }: any) =>
{children}
})) // Mock plugins +vi.mock('remark-alert', () => ({ __esModule: true, default: vi.fn() })) vi.mock('remark-gfm', () => ({ __esModule: true, default: vi.fn() })) vi.mock('remark-cjk-friendly', () => ({ __esModule: true, default: vi.fn() })) vi.mock('remark-math', () => ({ __esModule: true, default: vi.fn() })) @@ -113,6 +119,16 @@ vi.mock('../plugins/remarkDisableConstructs', () => ({ default: vi.fn() })) +vi.mock('../plugins/rehypeHeadingIds', () => ({ + __esModule: true, + default: vi.fn() +})) + +vi.mock('../plugins/rehypeScalableSvg', () => ({ + __esModule: true, + default: vi.fn() +})) + // Mock ReactMarkdown with realistic rendering vi.mock('react-markdown', () => ({ __esModule: true, @@ -331,7 +347,7 @@ describe('Markdown', () => { expect(tableComponent).toHaveAttribute('data-block-id', 'test-block-456') }) - it('should integrate ImagePreview component', () => { + it('should integrate ImageViewer component', () => { render() expect(screen.getByTestId('has-img-component')).toBeInTheDocument() diff --git a/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts b/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts new file mode 100644 index 0000000000..535075c4dd --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts @@ -0,0 +1,65 @@ +import type { Element, Root } from 'hast' +import { visit } from 'unist-util-visit' + +const isNumeric = (value: unknown): boolean => { + if (typeof value === 'string' && value.trim() !== '') { + return String(parseFloat(value)) === value.trim() + } + return false +} + +/** + * A Rehype plugin that prepares SVG elements for scalable rendering. + * + * This plugin classifies SVGs into two categories: + * + * 1. **Simple SVGs**: Those that already have a `viewBox` or have unitless + * numeric `width` and `height` attributes. These are processed directly + * in the HAST tree for maximum performance. A `viewBox` is added if + * missing, and fixed dimensions are removed. + * + * 2. **Complex SVGs**: Those without a `viewBox` and with dimensions that + * have units (e.g., "100pt", "10em"). These cannot be safely processed + * at the data layer. The plugin adds a `data-needs-measurement="true"` + * attribute to them, flagging them for runtime processing by a + * specialized React component. + * + * @returns A unified transformer function. + */ +function rehypeScalableSvg() { + return (tree: Root) => { + visit(tree, 'element', (node: Element) => { + if (node.tagName === 'svg') { + const properties = node.properties || {} + const hasViewBox = 'viewBox' in properties + const width = properties.width as string | undefined + const height = properties.height as string | undefined + + // 1. Universally set max-width from the width attribute if it exists. + // This is safe for both simple and complex cases. + if (width) { + const existingStyle = properties.style ? String(properties.style).trim().replace(/;$/, '') : '' + const maxWidth = `max-width: ${width}` + properties.style = existingStyle ? `${existingStyle}; ${maxWidth}` : maxWidth + } + + // 2. Handle viewBox creation for simple, numeric cases. + if (!hasViewBox && isNumeric(width) && isNumeric(height)) { + properties.viewBox = `0 0 ${width} ${height}` + } + // 3. Flag complex cases for runtime measurement. + else if (!hasViewBox && width && height) { + properties['data-needs-measurement'] = 'true' + } + + // 4. Reset or clean up attributes. + properties.width = '100%' + delete properties.height + + node.properties = properties + } + }) + } +} + +export default rehypeScalableSvg diff --git a/src/renderer/src/utils/__tests__/image.test.ts b/src/renderer/src/utils/__tests__/image.test.ts index fc658be50b..8ac56beae6 100644 --- a/src/renderer/src/utils/__tests__/image.test.ts +++ b/src/renderer/src/utils/__tests__/image.test.ts @@ -6,7 +6,8 @@ import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, compressImage, - convertToBase64 + convertToBase64, + makeSvgSizeAdaptive } from '../image' // mock 依赖 @@ -125,4 +126,79 @@ describe('utils/image', () => { expect(func).not.toHaveBeenCalled() }) }) + + describe('makeSvgSizeAdaptive', () => { + const createSvgElement = (svgString: string): SVGElement => { + const div = document.createElement('div') + div.innerHTML = svgString + const svgElement = div.querySelector('svg') + if (!svgElement) { + throw new Error(`Test setup error: No element found in string: "${svgString}"`) + } + return svgElement + } + + // Mock document.body.appendChild to avoid errors in jsdom + beforeEach(() => { + vi.spyOn(document.body, 'appendChild').mockImplementation(() => ({}) as Node) + vi.spyOn(document.body, 'removeChild').mockImplementation(() => ({}) as Node) + }) + + it('should measure and add viewBox/max-width when viewBox is missing', () => { + const svgElement = createSvgElement('') + // Mock the measurement result on the prototype + const spy = vi + .spyOn(SVGElement.prototype, 'getBoundingClientRect') + .mockReturnValue({ width: 133, height: 106 } as DOMRect) + + const result = makeSvgSizeAdaptive(svgElement) as SVGElement + + expect(spy).toHaveBeenCalled() + expect(result.getAttribute('viewBox')).toBe('0 0 133 106') + expect(result.style.maxWidth).toBe('133px') + expect(result.getAttribute('width')).toBe('100%') + expect(result.hasAttribute('height')).toBe(false) + + spy.mockRestore() // Clean up the prototype spy + }) + + it('should use width attribute for max-width when viewBox is present', () => { + const svgElement = createSvgElement('') + const spy = vi.spyOn(SVGElement.prototype, 'getBoundingClientRect') // Spy to ensure it's NOT called + + const result = makeSvgSizeAdaptive(svgElement) as SVGElement + + expect(spy).not.toHaveBeenCalled() + expect(result.getAttribute('viewBox')).toBe('0 0 50 50') + expect(result.style.maxWidth).toBe('100pt') + expect(result.getAttribute('width')).toBe('100%') + expect(result.hasAttribute('height')).toBe(false) + + spy.mockRestore() + }) + + it('should handle measurement failure gracefully', () => { + const svgElement = createSvgElement('') + // Mock a failed measurement + const spy = vi + .spyOn(SVGElement.prototype, 'getBoundingClientRect') + .mockReturnValue({ width: 0, height: 0 } as DOMRect) + + const result = makeSvgSizeAdaptive(svgElement) as SVGElement + + expect(result.hasAttribute('viewBox')).toBe(false) + expect(result.style.maxWidth).toBe('100pt') // Falls back to width attribute + expect(result.getAttribute('width')).toBe('100%') + + spy.mockRestore() + }) + + it('should return the element unchanged if it is not an SVGElement', () => { + const divElement = document.createElement('div') + const originalOuterHTML = divElement.outerHTML + const result = makeSvgSizeAdaptive(divElement) + + expect(result.outerHTML).toBe(originalOuterHTML) + }) + }) }) diff --git a/src/renderer/src/utils/image.ts b/src/renderer/src/utils/image.ts index a6ff7db536..533f3defe1 100644 --- a/src/renderer/src/utils/image.ts +++ b/src/renderer/src/utils/image.ts @@ -270,3 +270,81 @@ export const svgToSvgBlob = (svgElement: SVGElement): Blob => { const svgData = new XMLSerializer().serializeToString(svgElement) return new Blob([svgData], { type: 'image/svg+xml' }) } + +/** + * 使用离屏容器测量 DOM 元素的渲染尺寸 + * @param element 要测量的元素 + * @returns 渲染元素的宽度和高度(以像素为单位) + */ +function measureElementSize(element: Element): { width: number; height: number } { + const clone = element.cloneNode(true) as Element + + // 检查元素类型并重置样式 + if (clone instanceof HTMLElement || clone instanceof SVGElement) { + clone.style.width = '' + clone.style.height = '' + clone.style.position = '' + clone.style.visibility = '' + } + + // 创建一个离屏容器 + const container = document.createElement('div') + container.style.position = 'absolute' + container.style.top = '-9999px' + container.style.left = '-9999px' + container.style.visibility = 'hidden' + + container.appendChild(clone) + document.body.appendChild(container) + + // 测量并清理 + const rect = clone.getBoundingClientRect() + document.body.removeChild(container) + + return { width: rect.width, height: rect.height } +} + +/** + * 让 SVG 元素在容器内可缩放,用于“预览”功能。 + * - 补充缺失的 viewBox + * - 补充缺失的 max-width style + * - 把 width 改为 100% + * - 移除 height + */ +export const makeSvgSizeAdaptive = (element: Element): Element => { + // type guard + if (!(element instanceof SVGElement)) { + return element + } + + const hasViewBox = element.hasAttribute('viewBox') + const widthStr = element.getAttribute('width') + + let measuredWidth: number | undefined + + // 如果缺少 viewBox 属性,测量元素尺寸来创建 + if (!hasViewBox) { + const renderedSize = measureElementSize(element) + if (renderedSize.width > 0 && renderedSize.height > 0) { + measuredWidth = renderedSize.width + element.setAttribute('viewBox', `0 0 ${renderedSize.width} ${renderedSize.height}`) + } + } + + // 设置 max-width + // 优先使用测量得到的宽度值,否则回退到 width 属性值 + if (measuredWidth !== undefined) { + element.style.setProperty('max-width', `${measuredWidth}px`) + } else if (widthStr) { + element.style.setProperty('max-width', widthStr) + } + + // 调整 width 和 height + element.setAttribute('width', '100%') + element.removeAttribute('height') + + // FIXME: 移除 preserveAspectRatio 来避免某些图无法正常预览 + element.removeAttribute('preserveAspectRatio') + + return element +} diff --git a/yarn.lock b/yarn.lock index 276a523034..3a27d98704 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8552,6 +8552,7 @@ __metadata: dexie-react-hooks: "npm:^1.1.7" diff: "npm:^7.0.0" docx: "npm:^9.0.2" + dompurify: "npm:^3.2.6" dotenv-cli: "npm:^7.4.2" electron: "npm:37.2.3" electron-builder: "npm:26.0.15" @@ -11445,7 +11446,7 @@ __metadata: languageName: node linkType: hard -"dompurify@npm:^3.2.5": +"dompurify@npm:^3.2.5, dompurify@npm:^3.2.6": version: 3.2.6 resolution: "dompurify@npm:3.2.6" dependencies: From e2e8ded2c02f28909b6a384acd15cd21c28bf277 Mon Sep 17 00:00:00 2001 From: one Date: Sat, 16 Aug 2025 23:20:38 +0800 Subject: [PATCH 02/42] refactor: improve style for ManageModelList and Tooltips (#9227) * refactor(ManageModelsPopup): remove margin of Empty * chore: use destroyOnHidden rather than deprecated destroyTooltipOnHide * refactor: center Empty --- .../src/components/Popups/ApiKeyListPopup/item.tsx | 2 +- src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx | 2 +- src/renderer/src/pages/home/Messages/MessageGroup.tsx | 2 +- .../ProviderSettings/ModelList/ManageModelsList.tsx | 2 +- .../ProviderSettings/ModelList/ManageModelsPopup.tsx | 9 ++++++++- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx b/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx index 8150ee9b0c..75d32b8bc5 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx @@ -130,7 +130,7 @@ const ApiKeyItem: FC = ({ mouseEnterDelay={0.5} placement="top" // 确保不留下明文 - destroyTooltipOnHide> + destroyOnHidden> {maskApiKey(keyStatus.key)} diff --git a/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx b/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx index 25801bdeba..4197b0a957 100644 --- a/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx +++ b/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx @@ -142,7 +142,7 @@ const CustomNode: FC<{ data: any }> = ({ data }) => { color="rgba(0, 0, 0, 0.85)" mouseEnterDelay={0.3} mouseLeaveDelay={0.1} - destroyTooltipOnHide> + destroyOnHidden> { return ( = ({ modelGroups, provid return ( = ({ providerId, resolve }) => { ) : ( Date: Sat, 16 Aug 2025 23:32:26 +0800 Subject: [PATCH 03/42] feat(DMXAPI): new add painting seededit (#9226) adjust api --- .../src/pages/paintings/DmxapiPage.tsx | 21 ++++++++++++++----- .../pages/paintings/config/DmxapiConfig.ts | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/pages/paintings/DmxapiPage.tsx b/src/renderer/src/pages/paintings/DmxapiPage.tsx index a829320ddd..d9167ac69e 100644 --- a/src/renderer/src/pages/paintings/DmxapiPage.tsx +++ b/src/renderer/src/pages/paintings/DmxapiPage.tsx @@ -13,7 +13,7 @@ import FileManager from '@renderer/services/FileManager' import { useAppDispatch } from '@renderer/store' import { setGenerating } from '@renderer/store/runtime' import type { FileMetadata, PaintingsState } from '@renderer/types' -import { uuid } from '@renderer/utils' +import { convertToBase64, uuid } from '@renderer/utils' import { DmxapiPainting } from '@types' import { Avatar, Button, Input, InputNumber, Segmented, Select, Switch, Tooltip } from 'antd' import TextArea from 'antd/es/input/TextArea' @@ -364,7 +364,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { } // 准备V1生成请求函数 - const prepareV1GenerateRequest = (prompt: string, painting: DmxapiPainting) => { + const prepareV1GenerateRequest = async (prompt: string, painting: DmxapiPainting) => { const params = { prompt, model: painting.model, @@ -391,6 +391,13 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { params.prompt = prompt + ',风格:' + painting.style_type } + if (Array.isArray(fileMap.imageFiles) && fileMap.imageFiles.length > 0) { + const imageFile = fileMap.imageFiles[0] + if (imageFile instanceof File) { + params['image'] = await convertToBase64(imageFile) + } + } + return { body: JSON.stringify(params), headerExpand: headerExpand, @@ -508,13 +515,17 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { } // 准备请求配置函数 - const prepareRequestConfig = (prompt: string, painting: DmxapiPainting) => { + const prepareRequestConfig = async (prompt: string, painting: DmxapiPainting) => { // 根据模式和模型版本返回不同的请求配置 if ( painting.generationMode !== undefined && [generationModeType.MERGE, generationModeType.EDIT].includes(painting.generationMode) ) { - return prepareV2GenerateRequest(prompt, painting) + if (painting.model === 'seededit-3.0') { + return await prepareV1GenerateRequest(prompt, painting) + } else { + return prepareV2GenerateRequest(prompt, painting) + } } else { return prepareV1GenerateRequest(prompt, painting) } @@ -550,7 +561,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { dispatch(setGenerating(true)) // 准备请求配置 - const requestConfig = prepareRequestConfig(prompt, painting) + const requestConfig = await prepareRequestConfig(prompt, painting) // 发送API请求 const urls = await callApi(requestConfig, controller) diff --git a/src/renderer/src/pages/paintings/config/DmxapiConfig.ts b/src/renderer/src/pages/paintings/config/DmxapiConfig.ts index 6dbe820a78..43bdf33c2c 100644 --- a/src/renderer/src/pages/paintings/config/DmxapiConfig.ts +++ b/src/renderer/src/pages/paintings/config/DmxapiConfig.ts @@ -83,7 +83,7 @@ export const MODEOPTIONS = [ // 获取模型分组数据 export const GetModelGroup = async (): Promise => { try { - const response = await fetch('https://dmxapi.cn/cherry_painting_models.json') + const response = await fetch('https://dmxapi.cn/cherry_painting_models_v2.json') if (response.ok) { const data = await response.json() From 8b5a3f734c6c4323b4a7c9d14779f38cab62f48f Mon Sep 17 00:00:00 2001 From: chenxue Date: Sun, 17 Aug 2025 00:40:06 +0800 Subject: [PATCH 04/42] feat(aihubmix): painting support flux model & update web search rules & update default models (#9220) * feat: add painting flux model & update web search models * feat: update flux api --------- Co-authored-by: zhaochenxue --- src/renderer/src/config/models.ts | 88 +++++++++++++++---- src/renderer/src/i18n/locales/en-us.json | 8 +- src/renderer/src/i18n/locales/ja-jp.json | 8 +- src/renderer/src/i18n/locales/ru-ru.json | 8 +- src/renderer/src/i18n/locales/zh-cn.json | 8 +- src/renderer/src/i18n/locales/zh-tw.json | 8 +- .../src/pages/paintings/AihubmixPage.tsx | 25 +++++- .../pages/paintings/config/aihubmixConfig.tsx | 38 +++++++- src/renderer/src/types/index.ts | 3 + 9 files changed, 167 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index e6276cbb98..6e9c2326ee 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -638,35 +638,59 @@ export const SYSTEM_MODELS: Record = } ], aihubmix: [ + { + id: 'gpt-5', + provider: 'aihubmix', + name: 'gpt-5', + group: 'OpenAI' + }, + { + id: 'gpt-5-mini', + provider: 'aihubmix', + name: 'gpt-5-mini', + group: 'OpenAI' + }, + { + id: 'gpt-5-nano', + provider: 'aihubmix', + name: 'gpt-5-nano', + group: 'OpenAI' + }, + { + id: 'gpt-5-chat-latest', + provider: 'aihubmix', + name: 'gpt-5-chat-latest', + group: 'OpenAI' + }, { id: 'o3', provider: 'aihubmix', name: 'o3', - group: 'gpt' + group: 'OpenAI' }, { id: 'o4-mini', provider: 'aihubmix', name: 'o4-mini', - group: 'gpt' + group: 'OpenAI' }, { id: 'gpt-4.1', provider: 'aihubmix', name: 'gpt-4.1', - group: 'gpt' + group: 'OpenAI' }, { id: 'gpt-4o', provider: 'aihubmix', name: 'gpt-4o', - group: 'gpt' + group: 'OpenAI' }, { id: 'gpt-image-1', provider: 'aihubmix', name: 'gpt-image-1', - group: 'gpt' + group: 'OpenAI' }, { id: 'DeepSeek-V3', @@ -674,29 +698,59 @@ export const SYSTEM_MODELS: Record = name: 'DeepSeek-V3', group: 'DeepSeek' }, + { + id: 'DeepSeek-R1', + provider: 'aihubmix', + name: 'DeepSeek-R1', + group: 'DeepSeek' + }, { id: 'claude-sonnet-4-20250514', provider: 'aihubmix', name: 'claude-sonnet-4-20250514', - group: 'claude' + group: 'Claude' }, { - id: 'gemini-2.5-pro-preview-05-06', + id: 'gemini-2.5-pro', provider: 'aihubmix', - name: 'gemini-2.5-pro-preview-05-06', - group: 'gemini' + name: 'gemini-2.5-pro', + group: 'Gemini' }, { - id: 'gemini-2.5-flash-preview-05-20-nothink', + id: 'gemini-2.5-flash-nothink', provider: 'aihubmix', - name: 'gemini-2.5-flash-preview-05-20-nothink', - group: 'gemini' + name: 'gemini-2.5-flash-nothink', + group: 'Gemini' }, { id: 'gemini-2.5-flash', provider: 'aihubmix', name: 'gemini-2.5-flash', - group: 'gemini' + group: 'Gemini' + }, + { + id: 'Qwen3-235B-A22B-Instruct-2507', + provider: 'aihubmix', + name: 'Qwen3-235B-A22B-Instruct-2507', + group: 'qwen' + }, + { + id: 'kimi-k2-0711-preview', + provider: 'aihubmix', + name: 'kimi-k2-0711-preview', + group: 'moonshot' + }, + { + id: 'Llama-4-Scout-17B-16E-Instruct', + provider: 'aihubmix', + name: 'Llama-4-Scout-17B-16E-Instruct', + group: 'llama' + }, + { + id: 'Llama-4-Maverick-17B-128E-Instruct-FP8', + provider: 'aihubmix', + name: 'Llama-4-Maverick-17B-128E-Instruct-FP8', + group: 'llama' } ], @@ -2896,12 +2950,16 @@ export function isWebSearchModel(model: Model): boolean { } if (provider.id === 'aihubmix') { + // modelId 不以-search结尾 + if (!modelId.endsWith('-search') && GEMINI_SEARCH_REGEX.test(modelId)) { + return true + } + if (isOpenAIWebSearchModel(model)) { return true } - const models = ['gemini-2.0-flash-search', 'gemini-2.0-flash-exp-search', 'gemini-2.0-pro-exp-02-05-search'] - return models.includes(modelId) + return false } if (provider?.type === 'openai') { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 5b2169ebdd..86717e01b5 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1579,6 +1579,7 @@ "style_type_tip": "Style for edited image, only for V_2 and above" }, "generate": { + "height": "Height", "magic_prompt_option_tip": "Intelligently enhances prompts for better results", "model_tip": "Model version: V3 is the latest version, V2 is the previous model, V2A is the fast model, V_1 is the first-generation model, _TURBO is the acceleration version", "negative_prompt_tip": "Describe unwanted elements, only for V_1, V_1_TURBO, V_2, and V_2_TURBO", @@ -1586,8 +1587,11 @@ "person_generation": "Generate person", "person_generation_tip": "Allow model to generate person images", "rendering_speed_tip": "Controls rendering speed vs. quality trade-off, only available for V_3", + "safety_tolerance": "Safety Tolerance", + "safety_tolerance_tip": "Controls safety tolerance for image generation, only available for FLUX.1-Kontext-pro", "seed_tip": "Controls image generation randomness for reproducible results", - "style_type_tip": "Image generation style for V_2 and above" + "style_type_tip": "Image generation style for V_2 and above", + "width": "Width" }, "generated_image": "Generated Image", "go_to_settings": "Go to Settings", @@ -1642,7 +1646,7 @@ "prompt_enhancement_tip": "Rewrite prompts into detailed, model-friendly versions when switched on", "prompt_placeholder": "Describe the image you want to create, e.g. A serene lake at sunset with mountains in the background", "prompt_placeholder_edit": "Enter your image description, text drawing uses \"double quotes\" to wrap", - "prompt_placeholder_en": "Enter your image description, currently Imagen only supports English prompts", + "prompt_placeholder_en": "Enter your image description, currently only supports English prompts", "proxy_required": "Open the proxy and enable \"TUN mode\" to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported", "quality": "Quality", "quality_options": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index c9dfd6418b..b41b10c4c7 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1579,6 +1579,7 @@ "style_type_tip": "編集後の画像スタイル、V_2 以上のバージョンでのみ適用" }, "generate": { + "height": "高さ", "magic_prompt_option_tip": "生成効果を向上させるための提示詞を最適化します", "model_tip": "モデルバージョン:V2 は最新 API モデル、V2A は高速モデル、V_1 は初代モデル、_TURBO は高速処理版です", "negative_prompt_tip": "画像に含めたくない内容を説明します", @@ -1586,8 +1587,11 @@ "person_generation": "人物生成", "person_generation_tip": "人物画像を生成する", "rendering_speed_tip": "レンダリング速度と品質のバランスを調整します。V_3バージョンでのみ利用可能です", + "safety_tolerance": "安全耐性", + "safety_tolerance_tip": "画像生成の安全耐性を制御します。FLUX.1-Kontext-pro のみ利用可能です", "seed_tip": "画像生成のランダム性を制御して、同じ生成結果を再現します", - "style_type_tip": "画像生成スタイル、V_2 以上のバージョンでのみ適用" + "style_type_tip": "画像生成スタイル、V_2 以上のバージョンでのみ適用", + "width": "幅" }, "generated_image": "生成画像", "go_to_settings": "設定に移動", @@ -1642,7 +1646,7 @@ "prompt_enhancement_tip": "オンにすると、プロンプトを詳細でモデルに適したバージョンに書き直します", "prompt_placeholder": "作成したい画像を説明します。例:夕日の湖畔、遠くに山々", "prompt_placeholder_edit": "画像の説明を入力します。テキスト描画には '二重引用符' を使用します", - "prompt_placeholder_en": "「英語」の説明を入力します。Imagenは現在、英語のプロンプト語のみをサポートしています", + "prompt_placeholder_en": "「英語」の説明を入力します。は現在、英語のプロンプト語のみをサポートしています", "proxy_required": "打開代理並開啟TUN模式查看生成圖片或複製到瀏覽器開啟,後續會支持國內直連", "quality": "品質", "quality_options": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index e7b000c671..e689aecebf 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1579,6 +1579,7 @@ "style_type_tip": "Стиль изображения после редактирования, доступен только для версий V_2 и выше" }, "generate": { + "height": "Высота", "magic_prompt_option_tip": "Интеллектуально оптимизирует подсказки для улучшения эффекта генерации", "model_tip": "Версия модели: V2 - новейшая API модель, V2A - быстрая модель, V_1 - первое поколение, _TURBO - ускоренная версия", "negative_prompt_tip": "Описывает, что вы не хотите видеть в изображении", @@ -1586,8 +1587,11 @@ "person_generation": "Генерация персонажа", "person_generation_tip": "Разрешить модель генерировать изображения людей", "rendering_speed_tip": "Управляет балансом между скоростью рендеринга и качеством, доступно только для V_3", + "safety_tolerance": "Безопасность", + "safety_tolerance_tip": "Контролирует безопасность изображения, доступно только для FLUX.1-Kontext-pro", "seed_tip": "Контролирует случайность генерации изображений для воспроизведения одинаковых результатов", - "style_type_tip": "Стиль генерации изображений, доступен только для версий V_2 и выше" + "style_type_tip": "Стиль генерации изображений, доступен только для версий V_2 и выше", + "width": "Ширина" }, "generated_image": "Сгенерированное изображение", "go_to_settings": "Перейти в настройки", @@ -1642,7 +1646,7 @@ "prompt_enhancement_tip": "При включении переписывает промпт в более детальную, модель-ориентированную версию", "prompt_placeholder": "Опишите изображение, которое вы хотите создать, например, Спокойное озеро на закате с горами на заднем плане", "prompt_placeholder_edit": "Введите ваше описание изображения, текстовая отрисовка использует двойные кавычки для обертки", - "prompt_placeholder_en": "Введите описание изображения, в настоящее время Imagen поддерживает только английские подсказки", + "prompt_placeholder_en": "Введите описание изображения, в настоящее время поддерживает только английские подсказки", "proxy_required": "Сейчас необходимо открыть прокси для просмотра сгенерированных изображений, в будущем будет поддерживаться прямое соединение", "quality": "Качество", "quality_options": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8aedda3c48..9c0ee36467 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1579,6 +1579,7 @@ "style_type_tip": "编辑后的图像风格,仅适用于 V_2 及以上版本" }, "generate": { + "height": "高度", "magic_prompt_option_tip": "智能优化提示词以提升生成效果", "model_tip": "模型版本:V3 为最新版本,V2 为之前版本,V2A 为快速模型、V_1 为初代模型,_TURBO 为加速版本", "negative_prompt_tip": "描述不想在图像中出现的元素,仅支持 V_1、V_1_TURBO、V_2 和 V_2_TURBO 版本", @@ -1586,8 +1587,11 @@ "person_generation": "生成人物", "person_generation_tip": "允许模型生成人物图像", "rendering_speed_tip": "控制渲染速度与质量的平衡,仅适用于 V_3 版本", + "safety_tolerance": "安全容忍度", + "safety_tolerance_tip": "控制图像生成的安全容忍度,仅适用于 FLUX.1-Kontext-pro 版本", "seed_tip": "控制图像生成的随机性,用于复现相同的生成结果", - "style_type_tip": "图像生成风格,仅适用于 V_2 及以上版本" + "style_type_tip": "图像生成风格,仅适用于 V_2 及以上版本", + "width": "宽度" }, "generated_image": "生成图片", "go_to_settings": "去设置", @@ -1642,7 +1646,7 @@ "prompt_enhancement_tip": "开启后将提示重写为详细的、适合模型的版本", "prompt_placeholder": "描述你想创建的图片,例如:一个宁静的湖泊,夕阳西下,远处是群山", "prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹", - "prompt_placeholder_en": "输入 \"英文\" 图片描述,目前 Imagen 仅支持英文提示词", + "prompt_placeholder_en": "输入 \"英文\" 图片描述,目前仅支持英文提示词", "proxy_required": "打开代理并开启 \"TUN 模式\" 查看生成图片或复制到浏览器打开,后续会支持国内直连", "quality": "质量", "quality_options": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 188a7782c1..8a32912fd9 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1579,6 +1579,7 @@ "style_type_tip": "編輯後的圖像風格,僅適用於 V_2 及以上版本" }, "generate": { + "height": "高度", "magic_prompt_option_tip": "智能優化生成效果的提示詞", "model_tip": "模型版本:V2 是最新 API 模型,V2A 是高速模型,V_1 是初代模型,_TURBO 是高速處理版", "negative_prompt_tip": "描述不想在圖像中出現的內容", @@ -1586,8 +1587,11 @@ "person_generation": "人物生成", "person_generation_tip": "允許模型生成人物圖像", "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於 V_3 版本", + "safety_tolerance": "安全耐性", + "safety_tolerance_tip": "控制圖像生成的安全耐性,僅適用於 FLUX.1-Kontext-pro 版本", "seed_tip": "控制圖像生成的隨機性,以重現相同的生成結果", - "style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本" + "style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本", + "width": "寬度" }, "generated_image": "生成圖片", "go_to_settings": "去設置", @@ -1642,7 +1646,7 @@ "prompt_enhancement_tip": "開啟後將提示重寫為詳細的、適合模型的版本", "prompt_placeholder": "描述你想建立的圖片,例如:一個寧靜的湖泊,夕陽西下,遠處是群山", "prompt_placeholder_edit": "輸入你的圖片描述,文本繪製用 ' 雙引號 ' 包裹", - "prompt_placeholder_en": "輸入英文圖片描述,目前 Imagen 僅支持英文提示詞", + "prompt_placeholder_en": "輸入英文圖片描述,目前僅支持英文提示詞", "proxy_required": "打開代理並開啟”TUN 模式 “查看生成圖片或複製到瀏覽器開啟,後續會支持國內直連", "quality": "品質", "quality_options": { diff --git a/src/renderer/src/pages/paintings/AihubmixPage.tsx b/src/renderer/src/pages/paintings/AihubmixPage.tsx index 4d5d8f0f75..2c3075148c 100644 --- a/src/renderer/src/pages/paintings/AihubmixPage.tsx +++ b/src/renderer/src/pages/paintings/AihubmixPage.tsx @@ -314,6 +314,18 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { headers = { Authorization: `Bearer ${aihubmixProvider.apiKey}` } + } else if (painting.model === 'FLUX.1-Kontext-pro') { + requestData = { + prompt, + model: painting.model, + // width: painting.width, + // height: painting.height, + safety_tolerance: painting.safetyTolerance || 6 + } + url = aihubmixProvider.apiHost + `/v1/images/generations` + headers = { + Authorization: `Bearer ${aihubmixProvider.apiKey}` + } } else { // Existing V1/V2 API requestData = { @@ -470,6 +482,17 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { const data = await response.json() logger.silly(`通用API响应: ${data}`) + if (data.output) { + const base64s = data.output.b64_json.map((item) => item.bytesBase64) + const validFiles = await Promise.all( + base64s.map(async (base64) => { + return await window.api.file.saveBase64Image(base64) + }) + ) + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls: validFiles.map((file) => file.name) }) + return + } const urls = data.data.filter((item) => item.url).map((item) => item.url) const base64s = data.data.filter((item) => item.b64_json).map((item) => item.b64_json) @@ -859,7 +882,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { placeholder={ isTranslating ? t('paintings.translating') - : painting.model?.startsWith('imagen-') + : painting.model?.startsWith('imagen-') || painting.model?.startsWith('FLUX') ? t('paintings.prompt_placeholder_en') : t('paintings.prompt_placeholder_edit') } diff --git a/src/renderer/src/pages/paintings/config/aihubmixConfig.tsx b/src/renderer/src/pages/paintings/config/aihubmixConfig.tsx index e188303a32..719b50cbd1 100644 --- a/src/renderer/src/pages/paintings/config/aihubmixConfig.tsx +++ b/src/renderer/src/pages/paintings/config/aihubmixConfig.tsx @@ -88,6 +88,11 @@ export const createModeConfigs = (): Record => { { label: 'ideogram_V_1', value: 'V_1' }, { label: 'ideogram_V_1_TURBO', value: 'V_1_TURBO' } ] + }, + { + label: 'Flux', + title: 'Flux', + options: [{ label: 'FLUX.1-Kontext-pro', value: 'FLUX.1-Kontext-pro' }] } ] }, @@ -229,6 +234,36 @@ export const createModeConfigs = (): Record => { options: PERSON_GENERATION_OPTIONS, initialValue: 'ALLOW_ALL', condition: (painting) => Boolean(painting.model?.startsWith('imagen-')) + }, + // { + // type: 'slider', + // key: 'width', + // title: 'paintings.generate.width', + // min: 256, + // max: 1440, + // initialValue: 1024, + // step: 32, + // condition: (painting) => painting.model === 'FLUX.1-Kontext-pro' + // }, + // { + // type: 'slider', + // key: 'height', + // title: 'paintings.generate.height', + // min: 256, + // max: 1440, + // initialValue: 768, + // step: 32, + // condition: (painting) => painting.model === 'FLUX.1-Kontext-pro' + // }, + { + type: 'slider', + key: 'safetyTolerance', + title: 'paintings.generate.safety_tolerance', + tooltip: 'paintings.generate.safety_tolerance_tip', + min: 0, + max: 6, + initialValue: 6, + condition: (painting) => painting.model === 'FLUX.1-Kontext-pro' } ], remix: [ @@ -384,5 +419,6 @@ export const DEFAULT_PAINTING: PaintingAction = { quality: 'auto', moderation: 'auto', n: 1, - numberOfImages: 4 + numberOfImages: 4, + safetyTolerance: 6 } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 5f649c5e8f..4401e14d6e 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -405,6 +405,9 @@ export interface GeneratePainting extends PaintingParams { background?: string personGeneration?: GenerateImagesConfig['personGeneration'] numberOfImages?: number + safetyTolerance?: number + width?: number + height?: number } export interface EditPainting extends PaintingParams { From 4dad2a593bd6bedfad6218fd597765a5a1d4dcb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Sun, 17 Aug 2025 00:41:48 +0800 Subject: [PATCH 05/42] fix(export): robustly export reasoning and handle errors (#9221) * fix(export): robustly export reasoning and handle errors * fix(export): normalize
to newline before notion parsing * feat(i18n): add notion truncation and unify export warn keys * refactor(export): add typing, state guards, and error logging * fix(export): preserve existing
in reasoning when convert to html * feat(export): add DOMPurify sanitization for reasoning content * chore(deps): remove unused @types/dompurify dev dep * chore(deps): remove dompurify dependency Remove dompurify from package.json and yarn. The changes delete dompurify entries and simplify the lockfile resolution so the project no longer declares dompurify as a direct dependency. This cleans up unused dependency declarations and prevents installing dompurify when it is not required. --- package.json | 1 - src/renderer/src/i18n/locales/en-us.json | 16 +- src/renderer/src/i18n/locales/ja-jp.json | 16 +- src/renderer/src/i18n/locales/ru-ru.json | 16 +- src/renderer/src/i18n/locales/zh-cn.json | 16 +- src/renderer/src/i18n/locales/zh-tw.json | 16 +- src/renderer/src/i18n/translate/el-gr.json | 58 ++- src/renderer/src/i18n/translate/es-es.json | 58 ++- src/renderer/src/i18n/translate/fr-fr.json | 58 ++- src/renderer/src/i18n/translate/pt-pt.json | 58 ++- src/renderer/src/utils/export.ts | 405 +++++++++++++++------ yarn.lock | 3 +- 12 files changed, 511 insertions(+), 210 deletions(-) diff --git a/package.json b/package.json index 70ed89485c..a824984845 100644 --- a/package.json +++ b/package.json @@ -179,7 +179,6 @@ "dexie-react-hooks": "^1.1.7", "diff": "^7.0.0", "docx": "^9.0.2", - "dompurify": "^3.2.6", "dotenv-cli": "^7.4.2", "electron": "37.2.3", "electron-builder": "26.0.15", diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 86717e01b5..eb83ad5e8f 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -840,6 +840,9 @@ "created": "Created", "last_updated": "Last Updated", "messages": "Messages", + "notion": { + "reasoning_truncated": "Chain of thought cannot be chunked and has been truncated." + }, "user": "User" }, "files": { @@ -1257,7 +1260,8 @@ }, "notion": { "export": "Failed to export to Notion. Please check connection status and configuration according to documentation", - "no_api_key": "Notion ApiKey or Notion DatabaseID is not configured" + "no_api_key": "Notion ApiKey or Notion DatabaseID is not configured", + "no_content": "There is nothing to export to Notion." }, "siyuan": { "export": "Failed to export to Siyuan Note, please check connection status and configuration according to documentation", @@ -1383,14 +1387,8 @@ } }, "warn": { - "notion": { - "exporting": "Exporting to Notion, please do not request export repeatedly!" - }, - "siyuan": { - "exporting": "Exporting to Siyuan Note, please do not request export repeatedly!" - }, - "yuque": { - "exporting": "Exporting to Yuque, please do not request export repeatedly!" + "export": { + "exporting": "Another export is in progress. Please wait for the previous export to complete and then try again." } }, "warning": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index b41b10c4c7..fbee0004dd 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -840,6 +840,9 @@ "created": "作成日", "last_updated": "最終更新日", "messages": "メッセージ", + "notion": { + "reasoning_truncated": "思考過程がブロック分割できません。切り捨てられています。" + }, "user": "ユーザー" }, "files": { @@ -1257,7 +1260,8 @@ }, "notion": { "export": "Notionへのエクスポートに失敗しました。接続状態と設定を確認してください", - "no_api_key": "Notion ApiKey または Notion DatabaseID が設定されていません" + "no_api_key": "Notion ApiKey または Notion DatabaseID が設定されていません", + "no_content": "Notionにエクスポートできる内容がありません。" }, "siyuan": { "export": "思源ノートのエクスポートに失敗しました。接続状態を確認し、ドキュメントに従って設定を確認してください", @@ -1383,14 +1387,8 @@ } }, "warn": { - "notion": { - "exporting": "Notionにエクスポート中です。重複してエクスポートしないでください! " - }, - "siyuan": { - "exporting": "思源ノートにエクスポート中です。重複してエクスポートしないでください!" - }, - "yuque": { - "exporting": "語雀にエクスポート中です。重複してエクスポートしないでください!" + "export": { + "exporting": "他のエクスポートが実行中です。前のエクスポートが完了するまでお待ちください。" } }, "warning": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index e689aecebf..e9705991ff 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -840,6 +840,9 @@ "created": "Создано", "last_updated": "Последнее обновление", "messages": "Сообщения", + "notion": { + "reasoning_truncated": "Цепочка мыслей не может быть разбита на блоки, обрезана" + }, "user": "Пользователь" }, "files": { @@ -1257,7 +1260,8 @@ }, "notion": { "export": "Ошибка экспорта в Notion, пожалуйста, проверьте состояние подключения и настройки в документации", - "no_api_key": "Notion ApiKey или Notion DatabaseID не настроен" + "no_api_key": "Notion ApiKey или Notion DatabaseID не настроен", + "no_content": "Нет содержимого для экспорта в Notion" }, "siyuan": { "export": "Ошибка экспорта в Siyuan, пожалуйста, проверьте состояние подключения и настройки в документации", @@ -1383,14 +1387,8 @@ } }, "warn": { - "notion": { - "exporting": "Экспортируется в Notion, пожалуйста, не отправляйте повторные запросы!" - }, - "siyuan": { - "exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!" - }, - "yuque": { - "exporting": "Экспортируется в Yuque, пожалуйста, не отправляйте повторные запросы!" + "export": { + "exporting": "Выполняется другая экспортация, подождите завершения предыдущей операции экспорта и повторите попытку" } }, "warning": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 9c0ee36467..ae65e9360a 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -840,6 +840,9 @@ "created": "创建时间", "last_updated": "最后更新", "messages": "消息数", + "notion": { + "reasoning_truncated": "思维链无法分块,已截断" + }, "user": "用户" }, "files": { @@ -1257,7 +1260,8 @@ }, "notion": { "export": "导出 Notion 错误,请检查连接状态并对照文档检查配置", - "no_api_key": "未配置 Notion API Key 或 Notion Database ID" + "no_api_key": "未配置 Notion API Key 或 Notion Database ID", + "no_content": "无可导出到 Notion 的内容" }, "siyuan": { "export": "导出思源笔记失败,请检查连接状态并对照文档检查配置", @@ -1383,14 +1387,8 @@ } }, "warn": { - "notion": { - "exporting": "正在导出到 Notion, 请勿重复请求导出!" - }, - "siyuan": { - "exporting": "正在导出到思源笔记,请勿重复请求导出!" - }, - "yuque": { - "exporting": "正在导出语雀,请勿重复请求导出!" + "export": { + "exporting": "正在进行其他导出,请等待上一导出完成后重试" } }, "warning": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 8a32912fd9..442f957bf1 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -840,6 +840,9 @@ "created": "建立時間", "last_updated": "最後更新", "messages": "訊息數", + "notion": { + "reasoning_truncated": "思維鏈無法分塊,已截斷" + }, "user": "使用者" }, "files": { @@ -1257,7 +1260,8 @@ }, "notion": { "export": "匯出 Notion 錯誤,請檢查連接狀態並對照文件檢查設定", - "no_api_key": "未設定 Notion API Key 或 Notion Database ID" + "no_api_key": "未設定 Notion API Key 或 Notion Database ID", + "no_content": "沒有可匯出至 Notion 的內容" }, "siyuan": { "export": "導出思源筆記失敗,請檢查連接狀態並對照文檔檢查配置", @@ -1383,14 +1387,8 @@ } }, "warn": { - "notion": { - "exporting": "正在匯出到 Notion,請勿重複請求匯出!" - }, - "siyuan": { - "exporting": "正在導出到思源筆記,請勿重複請求導出!" - }, - "yuque": { - "exporting": "正在導出語雀,請勿重複請求導出!" + "export": { + "exporting": "正在進行其他匯出,請等待上一次匯出完成後再試" } }, "warning": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index d3e74849f9..a35bed49d3 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -648,6 +648,31 @@ }, "translate": "Μετάφραση" }, + "code": { + "auto_update_to_latest": "Έλεγχος για ενημερώσεις και εγκατάσταση της τελευταίας έκδοσης", + "bun_required_message": "Για τη λειτουργία του εργαλείου CLI πρέπει να εγκαταστήσετε το περιβάλλον Bun", + "cli_tool": "Εργαλείο CLI", + "cli_tool_placeholder": "Επιλέξτε το CLI εργαλείο που θέλετε να χρησιμοποιήσετε", + "description": "Εκκίνηση γρήγορα πολλών εργαλείων CLI κώδικα, για αύξηση της αποδοτικότητας ανάπτυξης", + "folder_placeholder": "Επιλέξτε κατάλογο εργασίας", + "install_bun": "Εγκατάσταση Bun", + "installing_bun": "Εγκατάσταση...", + "launch": { + "bun_required": "Παρακαλώ εγκαταστήστε πρώτα το περιβάλλον Bun πριν εκκινήσετε το εργαλείο CLI", + "error": "Η εκκίνηση απέτυχε, παρακαλώ δοκιμάστε ξανά", + "label": "Εκκίνηση", + "success": "Επιτυχής εκκίνηση", + "validation_error": "Συμπληρώστε όλα τα υποχρεωτικά πεδία: εργαλείο CLI, μοντέλο και κατάλογος εργασίας" + }, + "launching": "Εκκίνηση...", + "model": "μοντέλο", + "model_placeholder": "Επιλέξτε το μοντέλο που θα χρησιμοποιήσετε", + "model_required": "Επιλέξτε μοντέλο", + "select_folder": "Επιλογή φακέλου", + "title": "Εργαλεία κώδικα", + "update_options": "Ενημέρωση επιλογών", + "working_directory": "κατάλογος εργασίας" + }, "code_block": { "collapse": "συμπεριληφθείς", "copy": { @@ -815,6 +840,9 @@ "created": "Ημερομηνία Δημιουργίας", "last_updated": "Τελευταία ενημέρωση", "messages": "Αριθμός Μηνυμάτων", + "notion": { + "reasoning_truncated": "Η αλυσίδα σκέψης δεν μπορεί να διαιρεθεί, έχει κοπεί" + }, "user": "Χρήστης" }, "files": { @@ -1232,7 +1260,8 @@ }, "notion": { "export": "Σφάλμα στην εξαγωγή του Notion, παρακαλείστε να ελέγξετε τη σύνδεση και τη διαμόρφωση κατά τη διατύπωση του χειρισμού", - "no_api_key": "Δεν έχετε διαθέσιμο το API Key του Notion ή το ID της βάσης του Notion" + "no_api_key": "Δεν έχετε διαθέσιμο το API Key του Notion ή το ID της βάσης του Notion", + "no_content": "Δεν υπάρχει περιεχόμενο για εξαγωγή στο Notion" }, "siyuan": { "export": "Η έκθεση σημειώσεων Siyuan απέτυχε, ελέγξτε την κατάσταση σύνδεσης και τις ρυθμίσεις σύμφωνα με τα έγγραφα", @@ -1358,14 +1387,8 @@ } }, "warn": { - "notion": { - "exporting": "Εξαγωγή στο Notion, μην επαναλάβετε την διαδικασία εξαγωγής!" - }, - "siyuan": { - "exporting": "Γίνεται εξαγωγή στις σημειώσεις Siyuan· μην ξαναζητήσετε την έκθεση!" - }, - "yuque": { - "exporting": "Γίνεται έκθεση Yuque· μην ξαναζητήσετε την έκθεση!" + "export": { + "exporting": "Παρακαλώ περιμένετε την ολοκλήρωση της προηγούμενης εξαγωγής. Εκτελείται άλλη εξαγωγή." } }, "warning": { @@ -2690,6 +2713,17 @@ "title": "Εκκίνηση", "totray": "Εισαγωγή στην συνδρομή κατά την εκκίνηση" }, + "math": { + "engine": { + "label": "Μηχανισμός μαθηματικών τύπων", + "none": "κανένα" + }, + "single_dollar": { + "label": "ενεργοποίηση $...$", + "tip": "Επεξεργασία μαθηματικών τύπων που περικλείονται σε ένα μόνο σύμβολο δολαρίου $...$, προεπιλογή ενεργοποιημένη." + }, + "title": "Ρύθμιση μαθηματικών τύπων" + }, "mcp": { "actions": "Ενέργειες", "active": "Ενεργοποίηση", @@ -2920,10 +2954,6 @@ "title": "Ρυθμίσεις εισαγωγής" }, "markdown_rendering_input_message": "Markdown Rendering Input Message", - "math_engine": { - "label": "Μηχανική μαθηματικών εξισώσεων", - "none": "Κανένα" - }, "metrics": "Χρόνος πρώτου χαρακτήρα {{time_first_token_millsec}}ms | {{token_speed}} tokens ανά δευτερόλεπτο", "model": { "title": "Ρυθμίσεις μοντέλου" @@ -2935,6 +2965,7 @@ "none": "Χωρίς εμφάνιση" }, "prompt": "Λήμμα προτροπής", + "show_message_outline": "Εμφάνιση πλαισίου μηνύματος", "title": "Ρυθμίσεις μηνυμάτων", "use_serif_font": "Χρήση μορφής Serif" }, @@ -3561,6 +3592,7 @@ "title": { "agents": "Πράκτορες", "apps": "Εφαρμογές", + "code": "Κώδικας", "files": "Αρχεία", "home": "Αρχική Σελίδα", "knowledge": "Βάση Γνώσης", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 9352f4635d..559eedc98d 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -648,6 +648,31 @@ }, "translate": "Traducir" }, + "code": { + "auto_update_to_latest": "Comprobar actualizaciones e instalar la versión más reciente", + "bun_required_message": "Se requiere instalar el entorno Bun para ejecutar la herramienta de línea de comandos", + "cli_tool": "Herramienta de línea de comandos", + "cli_tool_placeholder": "Seleccione la herramienta de línea de comandos que desea utilizar", + "description": "Inicia rápidamente múltiples herramientas de línea de comandos para código, aumentando la eficiencia del desarrollo", + "folder_placeholder": "Seleccionar directorio de trabajo", + "install_bun": "Instalar Bun", + "installing_bun": "Instalando...", + "launch": { + "bun_required": "Instale el entorno Bun antes de iniciar la herramienta de línea de comandos", + "error": "Error al iniciar, intente nuevamente", + "label": "Iniciar", + "success": "Inicio exitoso", + "validation_error": "Complete all required fields: CLI tool, model, and working directory" + }, + "launching": "Iniciando...", + "model": "modelo", + "model_placeholder": "Seleccionar el modelo que se va a utilizar", + "model_required": "Seleccione el modelo", + "select_folder": "Seleccionar carpeta", + "title": "Herramientas de código", + "update_options": "Opciones de actualización", + "working_directory": "directorio de trabajo" + }, "code_block": { "collapse": "Replegar", "copy": { @@ -815,6 +840,9 @@ "created": "Fecha de creación", "last_updated": "Última actualización", "messages": "Mensajes", + "notion": { + "reasoning_truncated": "La cadena de pensamiento no se puede dividir en bloques, ha sido truncada" + }, "user": "Usuario" }, "files": { @@ -1232,7 +1260,8 @@ }, "notion": { "export": "Error de exportación de Notion, verifique el estado de conexión y la configuración según la documentación", - "no_api_key": "No se ha configurado la clave API de Notion o la ID de la base de datos de Notion" + "no_api_key": "No se ha configurado la clave API de Notion o la ID de la base de datos de Notion", + "no_content": "No hay contenido que exportar a Notion" }, "siyuan": { "export": "Error al exportar la nota de Siyuan, verifique el estado de la conexión y revise la configuración según la documentación", @@ -1358,14 +1387,8 @@ } }, "warn": { - "notion": { - "exporting": "Se está exportando a Notion, ¡no solicite nuevamente la exportación!" - }, - "siyuan": { - "exporting": "Exportando a Siyuan, ¡no solicite la exportación nuevamente!" - }, - "yuque": { - "exporting": "Exportando Yuque, ¡no solicite la exportación nuevamente!" + "export": { + "exporting": "Realizando otra exportación, espere a que finalice la anterior para intentarlo de nuevo" } }, "warning": { @@ -2690,6 +2713,17 @@ "title": "Inicio", "totray": "Minimizar a la bandeja al iniciar" }, + "math": { + "engine": { + "label": "Motor de fórmulas matemáticas", + "none": "sin contenido" + }, + "single_dollar": { + "label": "habilitar $...$", + "tip": "Renderiza fórmulas matemáticas encerradas entre un único símbolo de dólar $...$, habilitado por defecto." + }, + "title": "Configuración de fórmulas matemáticas" + }, "mcp": { "actions": "Acciones", "active": "Activar", @@ -2920,10 +2954,6 @@ "title": "Configuración de entrada" }, "markdown_rendering_input_message": "Renderizar mensajes de entrada en Markdown", - "math_engine": { - "label": "Motor de fórmulas matemáticas", - "none": "Ninguno" - }, "metrics": "Retraso inicial {{time_first_token_millsec}}ms | {{token_speed}} tokens por segundo", "model": { "title": "Configuración del modelo" @@ -2935,6 +2965,7 @@ "none": "No mostrar" }, "prompt": "Palabra de indicación", + "show_message_outline": "Mostrar esquema del mensaje", "title": "Configuración de mensajes", "use_serif_font": "Usar fuente serif" }, @@ -3561,6 +3592,7 @@ "title": { "agents": "Agentes", "apps": "Aplicaciones", + "code": "Código", "files": "Archivos", "home": "Inicio", "knowledge": "Base de conocimiento", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index b0c4beee9b..b2e996a7ad 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -648,6 +648,31 @@ }, "translate": "Traduire" }, + "code": { + "auto_update_to_latest": "Vérifier les mises à jour et installer la dernière version", + "bun_required_message": "L'exécution de l'outil en ligne de commande nécessite l'installation de l'environnement Bun", + "cli_tool": "Outil CLI", + "cli_tool_placeholder": "Sélectionnez l'outil CLI à utiliser", + "description": "Lancer rapidement plusieurs outils CLI de code pour améliorer l'efficacité du développement", + "folder_placeholder": "Sélectionner le répertoire de travail", + "install_bun": "Installer Bun", + "installing_bun": "Installation en cours...", + "launch": { + "bun_required": "Veuillez d'abord installer l'environnement Bun avant de lancer l'outil en ligne de commande", + "error": "Échec du démarrage, veuillez réessayer", + "label": "Démarrer", + "success": "Démarrage réussi", + "validation_error": "Veuillez remplir tous les champs obligatoires : outil CLI, modèle et répertoire de travail" + }, + "launching": "En cours de démarrage...", + "model": "modèle", + "model_placeholder": "Sélectionnez le modèle à utiliser", + "model_required": "Veuillez sélectionner le modèle", + "select_folder": "Sélectionner le dossier", + "title": "Outils de code", + "update_options": "Options de mise à jour", + "working_directory": "répertoire de travail" + }, "code_block": { "collapse": "Réduire", "copy": { @@ -815,6 +840,9 @@ "created": "Date de création", "last_updated": "Dernière mise à jour", "messages": "Messages", + "notion": { + "reasoning_truncated": "La chaîne de pensée ne peut pas être fractionnée, elle a été tronquée." + }, "user": "Utilisateur" }, "files": { @@ -1232,7 +1260,8 @@ }, "notion": { "export": "Erreur lors de l'exportation vers Notion, veuillez vérifier l'état de la connexion et la configuration dans la documentation", - "no_api_key": "Aucune clé API Notion ou ID de base de données Notion configurée" + "no_api_key": "Aucune clé API Notion ou ID de base de données Notion configurée", + "no_content": "Aucun contenu à exporter vers Notion" }, "siyuan": { "export": "Échec de l'exportation de la note Siyuan, veuillez vérifier l'état de la connexion et la configuration indiquée dans le document", @@ -1358,14 +1387,8 @@ } }, "warn": { - "notion": { - "exporting": "Exportation en cours vers Notion, veuillez ne pas faire plusieurs demandes d'exportation!" - }, - "siyuan": { - "exporting": "Exportation vers Siyuan en cours, veuillez ne pas demander à exporter à nouveau !" - }, - "yuque": { - "exporting": "Exportation Yuque en cours, veuillez ne pas demander à exporter à nouveau !" + "export": { + "exporting": "Une autre exportation est en cours, veuillez patienter jusqu'à la fin de l'exportation précédente pour réessayer." } }, "warning": { @@ -2690,6 +2713,17 @@ "title": "Démarrage", "totray": "Minimiser dans la barre d'état système au démarrage" }, + "math": { + "engine": { + "label": "Moteur de formules mathématiques", + "none": "Aucun" + }, + "single_dollar": { + "label": "activer $...$", + "tip": "Rendu des formules mathématiques encapsulées par un seul symbole dollar $...$, activé par défaut." + }, + "title": "Configuration des formules mathématiques" + }, "mcp": { "actions": "Actions", "active": "Activer", @@ -2920,10 +2954,6 @@ "title": "Paramètres d'entrée" }, "markdown_rendering_input_message": "Rendu Markdown des messages d'entrée", - "math_engine": { - "label": "Moteur de formules mathématiques", - "none": "Aucun" - }, "metrics": "Latence initiale {{time_first_token_millsec}}ms | Vitesse de tokenisation {{token_speed}} tokens/s", "model": { "title": "Paramètres du modèle" @@ -2935,6 +2965,7 @@ "none": "Ne pas afficher" }, "prompt": "Mot-clé d'affichage", + "show_message_outline": "Afficher le plan du message", "title": "Paramètres des messages", "use_serif_font": "Utiliser une police serif" }, @@ -3561,6 +3592,7 @@ "title": { "agents": "Agent intelligent", "apps": "Mini-programmes", + "code": "Code", "files": "Fichiers", "home": "Page d'accueil", "knowledge": "Base de connaissances", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 72f2dd5998..be07031b5f 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -648,6 +648,31 @@ }, "translate": "Traduzir" }, + "code": { + "auto_update_to_latest": "Verificar atualizações e instalar a versão mais recente", + "bun_required_message": "Executar a ferramenta CLI requer a instalação do ambiente Bun", + "cli_tool": "Ferramenta de linha de comando", + "cli_tool_placeholder": "Selecione a ferramenta de linha de comando a ser utilizada", + "description": "Inicie rapidamente várias ferramentas de linha de comando de código, aumentando a eficiência do desenvolvimento", + "folder_placeholder": "Selecionar diretório de trabalho", + "install_bun": "Instalar o Bun", + "installing_bun": "Instalando...", + "launch": { + "bun_required": "Instale o ambiente Bun antes de iniciar a ferramenta de linha de comando", + "error": "Falha ao iniciar, tente novamente", + "label": "iniciar", + "success": "Início bem-sucedido", + "validation_error": "Preencha todos os campos obrigatórios: ferramenta CLI, modelo e diretório de trabalho" + }, + "launching": "Iniciando...", + "model": "modelo", + "model_placeholder": "Selecione o modelo a ser utilizado", + "model_required": "Selecione o modelo", + "select_folder": "Selecionar pasta", + "title": "Ferramenta de código", + "update_options": "Opções de atualização", + "working_directory": "diretório de trabalho" + }, "code_block": { "collapse": "Recolher", "copy": { @@ -815,6 +840,9 @@ "created": "Criado em", "last_updated": "Última Atualização", "messages": "Mensagens", + "notion": { + "reasoning_truncated": "A cadeia de pensamento não pode ser dividida em partes, foi interrompida" + }, "user": "Usuário" }, "files": { @@ -1232,7 +1260,8 @@ }, "notion": { "export": "Erro ao exportar Notion, verifique o status da conexão e a configuração de acordo com a documentação", - "no_api_key": "API Key ou Notion Database ID não configurados" + "no_api_key": "API Key ou Notion Database ID não configurados", + "no_content": "Nenhum conteúdo para exportar para o Notion" }, "siyuan": { "export": "Falha ao exportar nota do Siyuan, verifique o estado da conexão e confira a configuração no documento", @@ -1358,14 +1387,8 @@ } }, "warn": { - "notion": { - "exporting": "Exportando para Notion, não solicite novamente a exportação!" - }, - "siyuan": { - "exporting": "Exportando para o Siyuan, por favor não solicite a exportação novamente!" - }, - "yuque": { - "exporting": "Exportando para Yuque, por favor não solicite a exportação novamente!" + "export": { + "exporting": "A exportação de outros arquivos está em andamento, aguarde a conclusão da exportação anterior e tente novamente." } }, "warning": { @@ -2690,6 +2713,17 @@ "title": "Inicialização", "totray": "Minimizar para bandeja ao iniciar" }, + "math": { + "engine": { + "label": "Motor de fórmulas matemáticas", + "none": "sem conteúdo" + }, + "single_dollar": { + "label": "ativar $...$", + "tip": "Renderiza fórmulas matemáticas delimitadas por um único sinal de dólar $...$, habilitado por padrão." + }, + "title": "Configuração de fórmulas matemáticas" + }, "mcp": { "actions": "Ações", "active": "Ativar", @@ -2920,10 +2954,6 @@ "title": "Configurações de entrada" }, "markdown_rendering_input_message": "Renderização de markdown na entrada de mensagens", - "math_engine": { - "label": "Motor de fórmulas matemáticas", - "none": "Nenhum" - }, "metrics": "Atraso inicial {{time_first_token_millsec}}ms | Taxa de token por segundo {{token_speed}} tokens", "model": { "title": "Configurações de modelo" @@ -2935,6 +2965,7 @@ "none": "Não mostrar" }, "prompt": "Exibir palavra-chave", + "show_message_outline": "Exibir esboço da mensagem", "title": "Configurações de mensagem", "use_serif_font": "Usar fonte serif" }, @@ -3561,6 +3592,7 @@ "title": { "agents": "Agentes", "apps": "Miniaplicativos", + "code": "Código", "files": "Arquivos", "home": "Página Inicial", "knowledge": "Base de Conhecimento", diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index bc7d9b20ad..a7f1a03a6e 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -12,10 +12,112 @@ import { convertMathFormula, markdownToPlainText } from '@renderer/utils/markdow import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find' import { markdownToBlocks } from '@tryfabric/martian' import dayjs from 'dayjs' +import DOMPurify from 'dompurify' import { appendBlocks } from 'notion-helper' // 引入 notion-helper 的 appendBlocks 函数 const logger = loggerService.withContext('Utils:export') +// 全局的导出状态获取函数 +const getExportState = () => store.getState().runtime.export.isExporting + +// 全局的导出状态设置函数,使用 dispatch 保障 Redux 状态更新正确 +const setExportingState = (isExporting: boolean) => { + store.dispatch(setExportState({ isExporting })) +} + +/** + * 安全地处理思维链内容,保留安全的 HTML 标签如
,移除危险内容 + * + * 支持的标签: + * - 结构:br, p, div, span, h1-h6, blockquote + * - 格式:strong, b, em, i, u, s, del, mark, small, sup, sub + * - 列表:ul, ol, li + * - 代码:code, pre, kbd, var, samp + * - 表格:table, thead, tbody, tfoot, tr, td, th + * + * @param content 原始思维链内容 + * @returns 安全处理后的内容 + */ +const sanitizeReasoningContent = (content: string): string => { + // 先处理换行符转换为
+ const contentWithBr = content.replace(/\n/g, '
') + + // 使用 DOMPurify 清理内容,保留常用的安全标签和属性 + const cleanContent = DOMPurify.sanitize(contentWithBr, { + ALLOWED_TAGS: [ + // 换行和基础结构 + 'br', + 'p', + 'div', + 'span', + // 文本格式化 + 'strong', + 'b', + 'em', + 'i', + 'u', + 's', + 'del', + 'mark', + 'small', + // 上标下标(数学公式、引用等) + 'sup', + 'sub', + // 标题 + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + // 引用 + 'blockquote', + // 列表 + 'ul', + 'ol', + 'li', + // 代码相关 + 'code', + 'pre', + 'kbd', + 'var', + 'samp', + // 表格(AI输出中可能包含表格) + 'table', + 'thead', + 'tbody', + 'tfoot', + 'tr', + 'td', + 'th', + // 分隔线 + 'hr' + ], + ALLOWED_ATTR: [ + // 安全的通用属性 + 'class', + 'title', + 'lang', + 'dir', + // code 标签的语言属性 + 'data-language', + // 表格属性 + 'colspan', + 'rowspan', + // 列表属性 + 'start', + 'type' + ], + KEEP_CONTENT: true, // 保留被移除标签的文本内容 + RETURN_DOM: false, + SANITIZE_DOM: true, + // 允许的协议(预留,虽然目前没有允许链接标签) + ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i + }) + + return cleanContent +} + /** * 获取话题的消息列表,使用TopicManager确保消息被正确加载 * 这样可以避免从未打开过的话题导出为空的问题 @@ -33,7 +135,7 @@ async function fetchTopicMessages(topicId: string): Promise { * @param {number} [length=80] 标题最大长度,默认为 80 * @returns {string} 提取的标题 */ -export function getTitleFromString(str: string, length: number = 80) { +export function getTitleFromString(str: string, length: number = 80): string { let title = str.trimStart().split('\n')[0] if (title.includes('。')) { @@ -57,7 +159,7 @@ export function getTitleFromString(str: string, length: number = 80) { return title } -const getRoleText = (role: string, modelName?: string, providerId?: string) => { +const getRoleText = (role: string, modelName?: string, providerId?: string): string => { const { showModelNameInMarkdown, showModelProviderInMarkdown } = store.getState().settings if (role === 'user') { @@ -166,7 +268,7 @@ const createBaseMarkdown = ( includeReasoning: boolean = false, excludeCitations: boolean = false, normalizeCitations: boolean = true -) => { +): { titleSection: string; reasoningSection: string; contentSection: string; citation: string } => { const { forceDollarMathInMarkdown } = store.getState().settings const roleText = getRoleText(message.role, message.model?.name, message.model?.provider) const titleSection = `## ${roleText}` @@ -180,13 +282,8 @@ const createBaseMarkdown = ( } else if (reasoningContent.startsWith('')) { reasoningContent = reasoningContent.substring(7) } - reasoningContent = reasoningContent - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(/\n/g, '
') + // 使用 DOMPurify 安全地处理思维链内容 + reasoningContent = sanitizeReasoningContent(reasoningContent) if (forceDollarMathInMarkdown) { reasoningContent = convertMathFormula(reasoningContent) } @@ -216,7 +313,7 @@ const createBaseMarkdown = ( return { titleSection, reasoningSection, contentSection: processedContent, citation } } -export const messageToMarkdown = (message: Message, excludeCitations?: boolean) => { +export const messageToMarkdown = (message: Message, excludeCitations?: boolean): string => { const { excludeCitationsInExport, standardizeCitationsInExport } = store.getState().settings const shouldExcludeCitations = excludeCitations ?? excludeCitationsInExport const { titleSection, contentSection, citation } = createBaseMarkdown( @@ -228,7 +325,7 @@ export const messageToMarkdown = (message: Message, excludeCitations?: boolean) return [titleSection, '', contentSection, citation].join('\n') } -export const messageToMarkdownWithReasoning = (message: Message, excludeCitations?: boolean) => { +export const messageToMarkdownWithReasoning = (message: Message, excludeCitations?: boolean): string => { const { excludeCitationsInExport, standardizeCitationsInExport } = store.getState().settings const shouldExcludeCitations = excludeCitations ?? excludeCitationsInExport const { titleSection, reasoningSection, contentSection, citation } = createBaseMarkdown( @@ -237,10 +334,14 @@ export const messageToMarkdownWithReasoning = (message: Message, excludeCitation shouldExcludeCitations, standardizeCitationsInExport ) - return [titleSection, '', reasoningSection + contentSection, citation].join('\n') + return [titleSection, '', reasoningSection, contentSection, citation].join('\n') } -export const messagesToMarkdown = (messages: Message[], exportReasoning?: boolean, excludeCitations?: boolean) => { +export const messagesToMarkdown = ( + messages: Message[], + exportReasoning?: boolean, + excludeCitations?: boolean +): string => { return messages .map((message) => exportReasoning @@ -266,7 +367,11 @@ const messagesToPlainText = (messages: Message[]): string => { return messages.map(formatMessageAsPlainText).join('\n\n') } -export const topicToMarkdown = async (topic: Topic, exportReasoning?: boolean, excludeCitations?: boolean) => { +export const topicToMarkdown = async ( + topic: Topic, + exportReasoning?: boolean, + excludeCitations?: boolean +): Promise => { const topicName = `# ${topic.name}` const messages = await fetchTopicMessages(topic.id) @@ -290,7 +395,18 @@ export const topicToPlainText = async (topic: Topic): Promise => { return topicName } -export const exportTopicAsMarkdown = async (topic: Topic, exportReasoning?: boolean, excludeCitations?: boolean) => { +export const exportTopicAsMarkdown = async ( + topic: Topic, + exportReasoning?: boolean, + excludeCitations?: boolean +): Promise => { + if (getExportState()) { + window.message.warning({ content: i18n.t('message.warn.export.exporting'), key: 'markdown-exporting' }) + return + } + + setExportingState(true) + const { markdownExportPath } = store.getState().settings if (!markdownExportPath) { try { @@ -305,7 +421,9 @@ export const exportTopicAsMarkdown = async (topic: Topic, exportReasoning?: bool } } catch (error: any) { window.message.error({ content: i18n.t('message.error.markdown.export.specified'), key: 'markdown-error' }) - logger.debug(error) + logger.error('Failed to export topic as markdown:', error) + } finally { + setExportingState(false) } } else { try { @@ -316,7 +434,9 @@ export const exportTopicAsMarkdown = async (topic: Topic, exportReasoning?: bool window.message.success({ content: i18n.t('message.success.markdown.export.preconf'), key: 'markdown-success' }) } catch (error: any) { window.message.error({ content: i18n.t('message.error.markdown.export.preconf'), key: 'markdown-error' }) - logger.debug(error) + logger.error('Failed to export topic as markdown:', error) + } finally { + setExportingState(false) } } } @@ -325,7 +445,14 @@ export const exportMessageAsMarkdown = async ( message: Message, exportReasoning?: boolean, excludeCitations?: boolean -) => { +): Promise => { + if (getExportState()) { + window.message.warning({ content: i18n.t('message.warn.export.exporting'), key: 'markdown-exporting' }) + return + } + + setExportingState(true) + const { markdownExportPath } = store.getState().settings if (!markdownExportPath) { try { @@ -343,7 +470,9 @@ export const exportMessageAsMarkdown = async ( } } catch (error: any) { window.message.error({ content: i18n.t('message.error.markdown.export.specified'), key: 'markdown-error' }) - logger.debug(error) + logger.error('Failed to export message as markdown:', error) + } finally { + setExportingState(false) } } else { try { @@ -357,12 +486,14 @@ export const exportMessageAsMarkdown = async ( window.message.success({ content: i18n.t('message.success.markdown.export.preconf'), key: 'markdown-success' }) } catch (error: any) { window.message.error({ content: i18n.t('message.error.markdown.export.preconf'), key: 'markdown-error' }) - logger.debug(error) + logger.error('Failed to export message as markdown:', error) + } finally { + setExportingState(false) } } } -const convertMarkdownToNotionBlocks = async (markdown: string) => { +const convertMarkdownToNotionBlocks = async (markdown: string): Promise => { return markdownToBlocks(markdown) } @@ -371,77 +502,109 @@ const convertThinkingToNotionBlocks = async (thinkingContent: string): Promise标签转换为真正的换行符 + const processedContent = thinkingContent.replace(//g, '\n') - return thinkingBlocks + // 使用 markdownToBlocks 处理思维链内容 + const childrenBlocks = markdownToBlocks(processedContent) + + return [ + { + object: 'block', + type: 'toggle', + toggle: { + rich_text: [ + { + type: 'text', + text: { + content: '🤔 ' + i18n.t('common.reasoning_content') + }, + annotations: { + bold: true + } + } + ], + children: childrenBlocks + } + } + ] + } catch (error) { + logger.error('failed to process reasoning content:', error as Error) + // 发生错误时,回退到简单的段落处理 + return [ + { + object: 'block', + type: 'toggle', + toggle: { + rich_text: [ + { + type: 'text', + text: { + content: '🤔 ' + i18n.t('common.reasoning_content') + }, + annotations: { + bold: true + } + } + ], + children: [ + { + object: 'block', + type: 'paragraph', + paragraph: { + rich_text: [ + { + type: 'text', + text: { + content: + thinkingContent.length > 1800 + ? thinkingContent.substring(0, 1800) + '...\n' + i18n.t('export.notion.reasoning_truncated') + : thinkingContent + } + } + ] + } + } + ] + } + } + ] + } } -const executeNotionExport = async (title: string, allBlocks: any[]): Promise => { - const { isExporting } = store.getState().runtime.export - if (isExporting) { - window.message.warning({ content: i18n.t('message.warn.notion.exporting'), key: 'notion-exporting' }) - return null +const executeNotionExport = async (title: string, allBlocks: any[]): Promise => { + if (getExportState()) { + window.message.warning({ content: i18n.t('message.warn.export.exporting'), key: 'notion-exporting' }) + return false } - setExportState({ isExporting: true }) - - title = title.slice(0, 29) + '...' - const { notionDatabaseID, notionApiKey } = store.getState().settings if (!notionApiKey || !notionDatabaseID) { window.message.error({ content: i18n.t('message.error.notion.no_api_key'), key: 'notion-no-apikey-error' }) - setExportState({ isExporting: false }) - return null + return false + } + + if (allBlocks.length === 0) { + window.message.error({ content: i18n.t('message.error.notion.export'), key: 'notion-no-content-error' }) + return false + } + + setExportingState(true) + + // 限制标题长度 + if (title.length > 32) { + title = title.slice(0, 29) + '...' } try { const notion = new Client({ auth: notionApiKey }) - if (allBlocks.length === 0) { - throw new Error('No content to export') - } - window.message.loading({ content: i18n.t('message.loading.notion.preparing'), key: 'notion-preparing', duration: 0 }) - let mainPageResponse: any = null - let parentBlockId: string | null = null const response = await notion.pages.create({ parent: { database_id: notionDatabaseID }, @@ -451,34 +614,37 @@ const executeNotionExport = async (title: string, allBlocks: any[]): Promise 0) { - await appendBlocks({ - block_id: parentBlockId, - children: allBlocks, - client: notion - }) - } + + await appendBlocks({ + block_id: response.id, + children: allBlocks, + client: notion + }) + window.message.destroy('notion-exporting') window.message.success({ content: i18n.t('message.success.notion.export'), key: 'notion-success' }) - return mainPageResponse + return true } catch (error: any) { - window.message.error({ content: i18n.t('message.error.notion.export'), key: 'notion-export-progress' }) - logger.debug(error) - return null + // 清理可能存在的loading消息 + window.message.destroy('notion-preparing') + window.message.destroy('notion-exporting') + + logger.error('Notion export failed:', error) + window.message.error({ content: i18n.t('message.error.notion.export'), key: 'notion-export-error' }) + return false } finally { - setExportState({ isExporting: false }) + setExportingState(false) } } -export const exportMessageToNotion = async (title: string, content: string, message?: Message) => { +export const exportMessageToNotion = async (title: string, content: string, message?: Message): Promise => { const { notionExportReasoning } = store.getState().settings const notionBlocks = await convertMarkdownToNotionBlocks(content) @@ -498,7 +664,7 @@ export const exportMessageToNotion = async (title: string, content: string, mess return executeNotionExport(title, notionBlocks) } -export const exportTopicToNotion = async (topic: Topic) => { +export const exportTopicToNotion = async (topic: Topic): Promise => { const { notionExportReasoning, excludeCitationsInExport } = store.getState().settings const topicMessages = await fetchTopicMessages(topic.id) @@ -532,12 +698,11 @@ export const exportTopicToNotion = async (topic: Topic) => { return executeNotionExport(topic.name, allBlocks) } -export const exportMarkdownToYuque = async (title: string, content: string) => { - const { isExporting } = store.getState().runtime.export +export const exportMarkdownToYuque = async (title: string, content: string): Promise => { const { yuqueToken, yuqueRepoId } = store.getState().settings - if (isExporting) { - window.message.warning({ content: i18n.t('message.warn.yuque.exporting'), key: 'yuque-exporting' }) + if (getExportState()) { + window.message.warning({ content: i18n.t('message.warn.export.exporting'), key: 'yuque-exporting' }) return } @@ -546,7 +711,7 @@ export const exportMarkdownToYuque = async (title: string, content: string) => { return } - setExportState({ isExporting: true }) + setExportingState(true) try { const response = await fetch(`https://www.yuque.com/api/v2/repos/${yuqueRepoId}/docs`, { @@ -602,7 +767,7 @@ export const exportMarkdownToYuque = async (title: string, content: string) => { }) return null } finally { - setExportState({ isExporting: false }) + setExportingState(false) } } @@ -617,7 +782,14 @@ export const exportMarkdownToYuque = async (title: string, content: string) => { * @param attributes.folder 选择的文件夹路径或文件路径 * @param attributes.vault 选择的Vault名称 */ -export const exportMarkdownToObsidian = async (attributes: any) => { +export const exportMarkdownToObsidian = async (attributes: any): Promise => { + if (getExportState()) { + window.message.warning({ content: i18n.t('message.warn.export.exporting'), key: 'obsidian-exporting' }) + return + } + + setExportingState(true) + try { // 从参数获取Vault名称 const obsidianVault = attributes.vault @@ -669,8 +841,10 @@ export const exportMarkdownToObsidian = async (attributes: any) => { window.open(obsidianUrl) window.message.success(i18n.t('chat.topics.export.obsidian_export_success')) } catch (error) { - logger.error('导出到Obsidian失败:', error as Error) + logger.error('Failed to export to Obsidian:', error as Error) window.message.error(i18n.t('chat.topics.export.obsidian_export_failed')) + } finally { + setExportingState(false) } } @@ -719,14 +893,24 @@ function transformObsidianFileName(fileName: string): string { return sanitized } -export const exportMarkdownToJoplin = async (title: string, contentOrMessages: string | Message | Message[]) => { +export const exportMarkdownToJoplin = async ( + title: string, + contentOrMessages: string | Message | Message[] +): Promise => { const { joplinUrl, joplinToken, joplinExportReasoning, excludeCitationsInExport } = store.getState().settings + if (getExportState()) { + window.message.warning({ content: i18n.t('message.warn.export.exporting'), key: 'joplin-exporting' }) + return + } + if (!joplinUrl || !joplinToken) { window.message.error(i18n.t('message.error.joplin.no_config')) return } + setExportingState(true) + let content: string if (typeof contentOrMessages === 'string') { content = contentOrMessages @@ -763,11 +947,13 @@ export const exportMarkdownToJoplin = async (title: string, contentOrMessages: s } window.message.success(i18n.t('message.success.joplin.export')) - return + return data } catch (error: any) { + logger.error('Failed to export to Joplin:', error) window.message.error(i18n.t('message.error.joplin.export')) - logger.debug(error) - return + return null + } finally { + setExportingState(false) } } @@ -776,12 +962,11 @@ export const exportMarkdownToJoplin = async (title: string, contentOrMessages: s * @param title 笔记标题 * @param content 笔记内容 */ -export const exportMarkdownToSiyuan = async (title: string, content: string) => { - const { isExporting } = store.getState().runtime.export +export const exportMarkdownToSiyuan = async (title: string, content: string): Promise => { const { siyuanApiUrl, siyuanToken, siyuanBoxId, siyuanRootPath } = store.getState().settings - if (isExporting) { - window.message.warning({ content: i18n.t('message.warn.siyuan.exporting'), key: 'siyuan-exporting' }) + if (getExportState()) { + window.message.warning({ content: i18n.t('message.warn.export.exporting'), key: 'siyuan-exporting' }) return } @@ -790,7 +975,7 @@ export const exportMarkdownToSiyuan = async (title: string, content: string) => return } - setExportState({ isExporting: true }) + setExportingState(true) try { // test connection @@ -826,13 +1011,13 @@ export const exportMarkdownToSiyuan = async (title: string, content: string) => key: 'siyuan-success' }) } catch (error) { - logger.error('导出到思源笔记失败:', error as Error) + logger.error('Failed to export to Siyuan:', error as Error) window.message.error({ content: i18n.t('message.error.siyuan.export') + (error instanceof Error ? `: ${error.message}` : ''), key: 'siyuan-error' }) } finally { - setExportState({ isExporting: false }) + setExportingState(false) } } /** diff --git a/yarn.lock b/yarn.lock index 3a27d98704..276a523034 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8552,7 +8552,6 @@ __metadata: dexie-react-hooks: "npm:^1.1.7" diff: "npm:^7.0.0" docx: "npm:^9.0.2" - dompurify: "npm:^3.2.6" dotenv-cli: "npm:^7.4.2" electron: "npm:37.2.3" electron-builder: "npm:26.0.15" @@ -11446,7 +11445,7 @@ __metadata: languageName: node linkType: hard -"dompurify@npm:^3.2.5, dompurify@npm:^3.2.6": +"dompurify@npm:^3.2.5": version: 3.2.6 resolution: "dompurify@npm:3.2.6" dependencies: From 535dcf477851342971d0cbb234a6d582b4fc5bac Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Sun, 17 Aug 2025 11:43:44 +0800 Subject: [PATCH 06/42] Fix/at symbol deletion issue (#9206) * fix: prevent incorrect @ symbol deletion in QuickPanel - Track trigger source (input vs button) and @ position - Only delete @ when triggered by input with model selection - Button-triggered panels never delete text content - Validate @ still exists at recorded position before deletion * feat: delete search text along with @ symbol - Pass searchText from QuickPanel to onClose callback - Delete both @ and search text (e.g., @cla) when model selected - Validate text matches before deletion for safety - Fallback to deleting only @ if text doesn't match * refactor: clarify ESC vs Backspace behavior in QuickPanel - ESC: Cancel operation, delete @ + searchText when models selected - Backspace: Natural editing, @ already deleted by browser, no extra action - Clear separation of intent improves predictability and UX --- .../src/components/QuickPanel/provider.tsx | 18 ++-- .../src/components/QuickPanel/types.ts | 12 ++- .../src/components/QuickPanel/view.tsx | 6 +- .../src/pages/home/Inputbar/Inputbar.tsx | 6 +- .../src/pages/home/Inputbar/InputbarTools.tsx | 4 +- .../home/Inputbar/MentionModelsButton.tsx | 83 ++++++++++++------- 6 files changed, 87 insertions(+), 42 deletions(-) diff --git a/src/renderer/src/components/QuickPanel/provider.tsx b/src/renderer/src/components/QuickPanel/provider.tsx index 0db0824934..c06d337248 100644 --- a/src/renderer/src/components/QuickPanel/provider.tsx +++ b/src/renderer/src/components/QuickPanel/provider.tsx @@ -5,7 +5,8 @@ import { QuickPanelCloseAction, QuickPanelContextType, QuickPanelListItem, - QuickPanelOpenOptions + QuickPanelOpenOptions, + QuickPanelTriggerInfo } from './types' const QuickPanelContext = createContext(null) @@ -19,9 +20,8 @@ export const QuickPanelProvider: React.FC = ({ children const [defaultIndex, setDefaultIndex] = useState(0) const [pageSize, setPageSize] = useState(7) const [multiple, setMultiple] = useState(false) - const [onClose, setOnClose] = useState< - ((Options: Pick) => void) | undefined - >() + const [triggerInfo, setTriggerInfo] = useState() + const [onClose, setOnClose] = useState<((Options: Partial) => void) | undefined>() const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>() const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>() @@ -44,6 +44,7 @@ export const QuickPanelProvider: React.FC = ({ children setPageSize(options.pageSize ?? 7) setMultiple(options.multiple ?? false) setSymbol(options.symbol) + setTriggerInfo(options.triggerInfo) setOnClose(() => options.onClose) setBeforeAction(() => options.beforeAction) @@ -53,9 +54,9 @@ export const QuickPanelProvider: React.FC = ({ children }, []) const close = useCallback( - (action?: QuickPanelCloseAction) => { + (action?: QuickPanelCloseAction, searchText?: string) => { setIsVisible(false) - onClose?.({ symbol, action }) + onClose?.({ symbol, action, triggerInfo, searchText, item: {} as QuickPanelListItem, multiple: false }) clearTimer.current = setTimeout(() => { setList([]) @@ -64,9 +65,10 @@ export const QuickPanelProvider: React.FC = ({ children setAfterAction(undefined) setTitle(undefined) setSymbol('') + setTriggerInfo(undefined) }, 200) }, - [onClose, symbol] + [onClose, symbol, triggerInfo] ) useEffect(() => { @@ -92,6 +94,7 @@ export const QuickPanelProvider: React.FC = ({ children defaultIndex, pageSize, multiple, + triggerInfo, onClose, beforeAction, afterAction @@ -107,6 +110,7 @@ export const QuickPanelProvider: React.FC = ({ children defaultIndex, pageSize, multiple, + triggerInfo, onClose, beforeAction, afterAction diff --git a/src/renderer/src/components/QuickPanel/types.ts b/src/renderer/src/components/QuickPanel/types.ts index 5c8f0edffd..8cf79fb270 100644 --- a/src/renderer/src/components/QuickPanel/types.ts +++ b/src/renderer/src/components/QuickPanel/types.ts @@ -1,6 +1,12 @@ import React from 'react' export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined +export type QuickPanelTriggerInfo = { + type: 'input' | 'button' + position?: number + originalText?: string +} + export type QuickPanelCallBackOptions = { symbol: string action: QuickPanelCloseAction @@ -8,6 +14,7 @@ export type QuickPanelCallBackOptions = { searchText?: string /** 是否处于多选状态 */ multiple?: boolean + triggerInfo?: QuickPanelTriggerInfo } export type QuickPanelOpenOptions = { @@ -26,6 +33,8 @@ export type QuickPanelOpenOptions = { * 可以是/@#符号,也可以是其他字符串 */ symbol: string + /** 触发信息,记录面板是如何被打开的 */ + triggerInfo?: QuickPanelTriggerInfo beforeAction?: (options: QuickPanelCallBackOptions) => void afterAction?: (options: QuickPanelCallBackOptions) => void onClose?: (options: QuickPanelCallBackOptions) => void @@ -51,7 +60,7 @@ export type QuickPanelListItem = { // 定义上下文类型 export interface QuickPanelContextType { readonly open: (options: QuickPanelOpenOptions) => void - readonly close: (action?: QuickPanelCloseAction) => void + readonly close: (action?: QuickPanelCloseAction, searchText?: string) => void readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void readonly isVisible: boolean readonly symbol: string @@ -60,6 +69,7 @@ export interface QuickPanelContextType { readonly defaultIndex: number readonly pageSize: number readonly multiple: boolean + readonly triggerInfo?: QuickPanelTriggerInfo readonly onClose?: (Options: QuickPanelCallBackOptions) => void readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index c955453903..34ebf07080 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -204,7 +204,9 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const handleClose = useCallback( (action?: QuickPanelCloseAction) => { - ctx.close(action) + // 传递 searchText 给 close 函数,去掉第一个字符(@ 或 /) + const cleanSearchText = searchText.length > 1 ? searchText.slice(1) : '' + ctx.close(action, cleanSearchText) setHistoryPanel([]) scrollTriggerRef.current = 'initial' @@ -217,7 +219,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { clearSearchText(true) } }, - [ctx, clearSearchText, setInputText] + [ctx, clearSearchText, setInputText, searchText] ) const handleItemAction = useCallback( diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index b84f4d6274..4257a1fa57 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -530,7 +530,11 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') { - inputbarToolsRef.current?.openMentionModelsPanel() + inputbarToolsRef.current?.openMentionModelsPanel({ + type: 'input', + position: cursorPosition - 1, + originalText: newText + }) } }, [enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate] diff --git a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx index 4f6f264ace..69baf4dafe 100644 --- a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx +++ b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx @@ -49,7 +49,7 @@ export interface InputbarToolsRef { openSelectFileMenu: () => void translate: () => void }) => QuickPanelListItem[] - openMentionModelsPanel: () => void + openMentionModelsPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void openAttachmentQuickPanel: () => void } @@ -292,7 +292,7 @@ const InputbarTools = ({ useImperativeHandle(ref, () => ({ getQuickPanelMenu: getQuickPanelMenuImpl, - openMentionModelsPanel: () => mentionModelsButtonRef.current?.openQuickPanel(), + openMentionModelsPanel: (triggerInfo) => mentionModelsButtonRef.current?.openQuickPanel(triggerInfo), openAttachmentQuickPanel: () => attachmentButtonRef.current?.openQuickPanel() })) diff --git a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx index 822d52fef6..9cc4d000b7 100644 --- a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx @@ -17,7 +17,7 @@ import { useNavigate } from 'react-router' import styled from 'styled-components' export interface MentionModelsButtonRef { - openQuickPanel: () => void + openQuickPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void } interface Props { @@ -137,42 +137,67 @@ const MentionModelsButton: FC = ({ return items }, [pinnedModels, providers, t, couldMentionNotVisionModel, mentionedModels, onMentionModel, navigate]) - const openQuickPanel = useCallback(() => { - // 重置模型动作标记 - hasModelActionRef.current = false + const openQuickPanel = useCallback( + (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => { + // 重置模型动作标记 + hasModelActionRef.current = false - quickPanel.open({ - title: t('agents.edit.model.select.title'), - list: modelItems, - symbol: '@', - multiple: true, - afterAction({ item }) { - item.isSelected = !item.isSelected - }, - onClose({ action }) { - // ESC或Backspace关闭时的特殊处理 - if (action === 'esc' || action === 'delete-symbol') { - // 如果有模型选择动作发生,删除@字符 - if (hasModelActionRef.current) { - // 使用React的setText来更新状态,而不是直接操作DOM - setText((currentText) => { - const lastAtIndex = currentText.lastIndexOf('@') - if (lastAtIndex !== -1) { - return currentText.slice(0, lastAtIndex) + currentText.slice(lastAtIndex + 1) - } - return currentText - }) + quickPanel.open({ + title: t('agents.edit.model.select.title'), + list: modelItems, + symbol: '@', + multiple: true, + triggerInfo: triggerInfo || { type: 'button' }, + afterAction({ item }) { + item.isSelected = !item.isSelected + }, + onClose({ action, triggerInfo: closeTriggerInfo, searchText }) { + // ESC关闭时的处理:删除 @ 和搜索文本 + if (action === 'esc') { + // 只有在输入触发且有模型选择动作时才删除@字符和搜索文本 + if ( + hasModelActionRef.current && + closeTriggerInfo?.type === 'input' && + closeTriggerInfo?.position !== undefined + ) { + // 使用React的setText来更新状态 + setText((currentText) => { + const position = closeTriggerInfo.position! + // 验证位置的字符是否仍是 @ + if (currentText[position] !== '@') { + return currentText + } + + // 计算删除范围:@ + searchText + const deleteLength = 1 + (searchText?.length || 0) + + // 验证要删除的内容是否匹配预期 + const expectedText = '@' + (searchText || '') + const actualText = currentText.slice(position, position + deleteLength) + + if (actualText !== expectedText) { + // 如果实际文本不匹配,只删除 @ 字符 + return currentText.slice(0, position) + currentText.slice(position + 1) + } + + // 删除 @ 和搜索文本 + return currentText.slice(0, position) + currentText.slice(position + deleteLength) + }) + } } + // Backspace删除@的情况(delete-symbol): + // @ 已经被Backspace自然删除,面板关闭,不需要额外操作 } - } - }) - }, [modelItems, quickPanel, t, setText]) + }) + }, + [modelItems, quickPanel, t, setText] + ) const handleOpenQuickPanel = useCallback(() => { if (quickPanel.isVisible && quickPanel.symbol === '@') { quickPanel.close() } else { - openQuickPanel() + openQuickPanel({ type: 'button' }) } }, [openQuickPanel, quickPanel]) From ded941b7b92e1266084dbaec01facb465768a905 Mon Sep 17 00:00:00 2001 From: one Date: Sun, 17 Aug 2025 13:22:59 +0800 Subject: [PATCH 07/42] refactor(Mermaid): render mermaid in shadow dom (#9187) * refactor(Mermaid): render mermaid in shadow dom * refactor: pass style overrides to renderSvgInShadowHost * refactor(MermaidPreview): separate measurement from rendering * refactor: rename hostCss to customCss * refactor: use custom properties in shadow host * test: update snapshots * fix: remove svg max-width * refactor: add viewBox to svg (experimental) * Revert "refactor: add viewBox to svg (experimental)" This reverts commit 8a265fa8a48d34b098fc050c516686ae5029a4e9. --- .../components/Preview/GraphvizPreview.tsx | 17 +++--- .../src/components/Preview/MermaidPreview.tsx | 53 ++++++++++++------- .../components/Preview/PlantUmlPreview.tsx | 3 +- .../src/components/Preview/SvgPreview.tsx | 3 +- .../GraphvizPreview.test.tsx.snap | 7 ++- .../MermaidPreview.test.tsx.snap | 7 ++- .../PlantUmlPreview.test.tsx.snap | 8 ++- .../__snapshots__/SvgPreview.test.tsx.snap | 8 ++- src/renderer/src/components/Preview/styles.ts | 12 +++++ src/renderer/src/components/Preview/utils.ts | 10 ++-- 10 files changed, 84 insertions(+), 44 deletions(-) diff --git a/src/renderer/src/components/Preview/GraphvizPreview.tsx b/src/renderer/src/components/Preview/GraphvizPreview.tsx index c3c5c641a2..578a35450b 100644 --- a/src/renderer/src/components/Preview/GraphvizPreview.tsx +++ b/src/renderer/src/components/Preview/GraphvizPreview.tsx @@ -1,9 +1,9 @@ import { AsyncInitializer } from '@renderer/utils/asyncInitializer' import React, { memo, useCallback } from 'react' -import styled from 'styled-components' import { useDebouncedRender } from './hooks/useDebouncedRender' import ImagePreviewLayout from './ImagePreviewLayout' +import { ShadowWhiteContainer } from './styles' import { BasicPreviewHandles, BasicPreviewProps } from './types' import { renderSvgInShadowHost } from './utils' @@ -13,8 +13,10 @@ const vizInitializer = new AsyncInitializer(async () => { return await module.instance() }) -/** 预览 Graphviz 图表 - * 使用 usePreviewRenderer hook 大幅简化组件逻辑 +/** + * 预览 Graphviz 图表 + * - 使用 useDebouncedRender 改善体验 + * - 使用 shadow dom 渲染 SVG */ const GraphvizPreview = ({ children, @@ -41,16 +43,9 @@ const GraphvizPreview = ({ ref={ref} imageRef={containerRef} source="graphviz"> - + ) } -const StyledGraphviz = styled.div` - overflow: auto; - position: relative; - width: 100%; - height: 100%; -` - export default memo(GraphvizPreview) diff --git a/src/renderer/src/components/Preview/MermaidPreview.tsx b/src/renderer/src/components/Preview/MermaidPreview.tsx index b4bd1b148e..86e0339c2f 100644 --- a/src/renderer/src/components/Preview/MermaidPreview.tsx +++ b/src/renderer/src/components/Preview/MermaidPreview.tsx @@ -1,15 +1,17 @@ import { nanoid } from '@reduxjs/toolkit' import { useMermaid } from '@renderer/hooks/useMermaid' import React, { memo, useCallback, useEffect, useRef, useState } from 'react' -import styled from 'styled-components' import { useDebouncedRender } from './hooks/useDebouncedRender' import ImagePreviewLayout from './ImagePreviewLayout' +import { ShadowTransparentContainer } from './styles' import { BasicPreviewHandles, BasicPreviewProps } from './types' +import { renderSvgInShadowHost } from './utils' -/** 预览 Mermaid 图表 - * 使用 usePreviewRenderer hook 重构,同时保留必要的可见性检测逻辑 - * FIXME: 等将来 mermaid-js 修复可见性问题后可以进一步简化 +/** + * 预览 Mermaid 图表 + * - 使用 useDebouncedRender 改善体验 + * - 使用 shadow dom 渲染 SVG */ const MermaidPreview = ({ children, @@ -20,17 +22,39 @@ const MermaidPreview = ({ const diagramId = useRef(`mermaid-${nanoid(6)}`).current const [isVisible, setIsVisible] = useState(true) - // 定义渲染函数 + /** + * 定义渲染函数,在临时容器中测量,在 shadow dom 中渲染。 + * 如果这个方案有问题,可以回退到 innerHTML。 + */ const renderMermaid = useCallback( async (content: string, container: HTMLDivElement) => { // 验证语法,提前抛出异常 await mermaid.parse(content) - const { svg } = await mermaid.render(diagramId, content, container) + // 获取容器宽度 + const { width } = container.getBoundingClientRect() + if (width === 0) return - // 避免不可见时产生 undefined 和 NaN - const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)') - container.innerHTML = fixedSvg + // 创建临时的 div 用于 mermaid 测量 + const measureEl = document.createElement('div') + measureEl.style.position = 'absolute' + measureEl.style.left = '-9999px' + measureEl.style.top = '-9999px' + measureEl.style.width = `${width}px` + document.body.appendChild(measureEl) + + try { + const { svg } = await mermaid.render(diagramId, content, measureEl) + + // 避免不可见时产生 undefined 和 NaN + const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)') + + // 有问题可以回退到 innerHTML + renderSvgInShadowHost(fixedSvg, container) + // container.innerHTML = fixedSvg + } finally { + document.body.removeChild(measureEl) + } }, [diagramId, mermaid] ) @@ -63,7 +87,7 @@ const MermaidPreview = ({ const element = containerRef.current if (!element) return - const currentlyVisible = element.offsetParent !== null + const currentlyVisible = element.offsetParent !== null && element.offsetWidth > 0 && element.offsetHeight > 0 setIsVisible(currentlyVisible) } @@ -105,16 +129,9 @@ const MermaidPreview = ({ ref={ref} imageRef={containerRef} source="mermaid"> - + ) } -const StyledMermaid = styled.div` - overflow: auto; - position: relative; - width: 100%; - height: 100%; -` - export default memo(MermaidPreview) diff --git a/src/renderer/src/components/Preview/PlantUmlPreview.tsx b/src/renderer/src/components/Preview/PlantUmlPreview.tsx index b94b87e187..f29a0c1ffc 100644 --- a/src/renderer/src/components/Preview/PlantUmlPreview.tsx +++ b/src/renderer/src/components/Preview/PlantUmlPreview.tsx @@ -4,6 +4,7 @@ import React, { memo, useCallback, useEffect } from 'react' import { useDebouncedRender } from './hooks/useDebouncedRender' import ImagePreviewLayout from './ImagePreviewLayout' +import { ShadowWhiteContainer } from './styles' import { BasicPreviewHandles, BasicPreviewProps } from './types' import { renderSvgInShadowHost } from './utils' @@ -128,7 +129,7 @@ const PlantUmlPreview = ({ ref={ref} imageRef={containerRef} source="plantuml"> -
+ ) } diff --git a/src/renderer/src/components/Preview/SvgPreview.tsx b/src/renderer/src/components/Preview/SvgPreview.tsx index d9a4689fcd..2122ebe091 100644 --- a/src/renderer/src/components/Preview/SvgPreview.tsx +++ b/src/renderer/src/components/Preview/SvgPreview.tsx @@ -2,6 +2,7 @@ import { memo, useCallback } from 'react' import { useDebouncedRender } from './hooks/useDebouncedRender' import ImagePreviewLayout from './ImagePreviewLayout' +import { ShadowWhiteContainer } from './styles' import { BasicPreviewHandles } from './types' import { renderSvgInShadowHost } from './utils' @@ -34,7 +35,7 @@ const SvgPreview = ({ children, enableToolbar = false, className, ref }: SvgPrev ref={ref} imageRef={containerRef} source="svg"> -
+ ) } diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/GraphvizPreview.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/GraphvizPreview.test.tsx.snap index 923f35b9e7..5ac34bfadd 100644 --- a/src/renderer/src/components/Preview/__tests__/__snapshots__/GraphvizPreview.test.tsx.snap +++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/GraphvizPreview.test.tsx.snap @@ -2,10 +2,9 @@ exports[`GraphvizPreview > basic rendering > should match snapshot 1`] = ` .c0 { - overflow: auto; - position: relative; - width: 100%; - height: 100%; + --shadow-host-background-color: white; + --shadow-host-border: 0.5px solid var(--color-code-background); + --shadow-host-border-radius: 8px; }
diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/MermaidPreview.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/MermaidPreview.test.tsx.snap index 01e98a63cd..1f87a151a8 100644 --- a/src/renderer/src/components/Preview/__tests__/__snapshots__/MermaidPreview.test.tsx.snap +++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/MermaidPreview.test.tsx.snap @@ -2,10 +2,9 @@ exports[`MermaidPreview > basic rendering > should match snapshot 1`] = ` .c0 { - overflow: auto; - position: relative; - width: 100%; - height: 100%; + --shadow-host-background-color: transparent; + --shadow-host-border: unset; + --shadow-host-border-radius: unset; }
diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/PlantUmlPreview.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/PlantUmlPreview.test.tsx.snap index 34d7840cb4..eb398f4cf5 100644 --- a/src/renderer/src/components/Preview/__tests__/__snapshots__/PlantUmlPreview.test.tsx.snap +++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/PlantUmlPreview.test.tsx.snap @@ -1,6 +1,12 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`PlantUmlPreview > basic rendering > should match snapshot 1`] = ` +.c0 { + --shadow-host-background-color: white; + --shadow-host-border: 0.5px solid var(--color-code-background); + --shadow-host-border-radius: 8px; +} +
basic rendering > should match snapshot 1`] = ` data-testid="preview-content" >
diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/SvgPreview.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/SvgPreview.test.tsx.snap index 00ee51b0e6..b96c8def5d 100644 --- a/src/renderer/src/components/Preview/__tests__/__snapshots__/SvgPreview.test.tsx.snap +++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/SvgPreview.test.tsx.snap @@ -1,6 +1,12 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`SvgPreview > basic rendering > should match snapshot 1`] = ` +.c0 { + --shadow-host-background-color: white; + --shadow-host-border: 0.5px solid var(--color-code-background); + --shadow-host-border-radius: 8px; +} +
basic rendering > should match snapshot 1`] = ` data-testid="preview-content" >
diff --git a/src/renderer/src/components/Preview/styles.ts b/src/renderer/src/components/Preview/styles.ts index aa8718b34e..521b96e225 100644 --- a/src/renderer/src/components/Preview/styles.ts +++ b/src/renderer/src/components/Preview/styles.ts @@ -33,3 +33,15 @@ export const PreviewContainer = styled(Flex).attrs({ role: 'alert' })` } } ` + +export const ShadowWhiteContainer = styled.div` + --shadow-host-background-color: white; + --shadow-host-border: 0.5px solid var(--color-code-background); + --shadow-host-border-radius: 8px; +` + +export const ShadowTransparentContainer = styled.div` + --shadow-host-background-color: transparent; + --shadow-host-border: unset; + --shadow-host-border-radius: unset; +` diff --git a/src/renderer/src/components/Preview/utils.ts b/src/renderer/src/components/Preview/utils.ts index e400062911..476a3b913a 100644 --- a/src/renderer/src/components/Preview/utils.ts +++ b/src/renderer/src/components/Preview/utils.ts @@ -28,11 +28,15 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme const style = document.createElement('style') style.textContent = ` :host { + --shadow-host-background-color: white; + --shadow-host-border: 0.5px solid var(--color-code-background); + --shadow-host-border-radius: 8px; + + background-color: var(--shadow-host-background-color); + border: var(--shadow-host-border); + border-radius: var(--shadow-host-border-radius); padding: 1em; - background-color: white; overflow: hidden; /* Prevent scrollbars, as scaling is now handled */ - border: 0.5px solid var(--color-code-background); - border-radius: 8px; display: block; position: relative; width: 100%; From 13a834ceaadd948f9bd83203a4f767b9bde75a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Sun, 17 Aug 2025 15:27:24 +0800 Subject: [PATCH 08/42] ci(PR-CI): grant only read permission for contents in CI (#9246) --- .github/workflows/pr-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 170c4ca909..2c15302bee 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -1,5 +1,8 @@ name: Pull Request CI +permissions: + contents: read + on: workflow_dispatch: pull_request: From f0bd6c97faa49f8dde802b562c3de1196e24846c Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sun, 17 Aug 2025 17:28:39 +0800 Subject: [PATCH 09/42] fix(providers): update not support enable_thinking providers (#9251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(providers): 更新不支持enable_thinking参数的系统提供商列表 将不支持enable_thinking参数的系统提供商明确设置为'ollama'和'lmstudio',移除之前的注释说明 --- src/renderer/src/config/providers.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index dd046a57ea..f4e31e891f 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -1270,8 +1270,7 @@ export const isSupportStreamOptionsProvider = (provider: Provider) => { ) } -// NOTE: 暂时不知道哪些系统提供商不支持该参数,先默认都支持。出问题的时候可以先用自定义参数顶着 -const NOT_SUPPORT_QWEN3_ENABLE_THINKING_PROVIDER = [] as const satisfies SystemProviderId[] +const NOT_SUPPORT_QWEN3_ENABLE_THINKING_PROVIDER = ['ollama', 'lmstudio'] as const satisfies SystemProviderId[] /** * 判断提供商是否支持使用 enable_thinking 参数来控制 Qwen3 等模型的思考。 Only for OpenAI Chat Completions API. From 635bc084b7ce8418e212060c130fd9c89cefed8d Mon Sep 17 00:00:00 2001 From: one Date: Sun, 17 Aug 2025 17:55:59 +0800 Subject: [PATCH 10/42] refactor: rename some MCP list (#9253) - BuiltinMCPServersSection -> BuiltinMCPServerList - McpResourcesSection -> McpMarketList --- ...rsSection.tsx => BuiltinMCPServerList.tsx} | 4 +- ...ResourcesSection.tsx => McpMarketList.tsx} | 38 +++++++++---------- .../settings/MCPSettings/McpServersList.tsx | 8 ++-- 3 files changed, 25 insertions(+), 25 deletions(-) rename src/renderer/src/pages/settings/MCPSettings/{BuiltinMCPServersSection.tsx => BuiltinMCPServerList.tsx} (98%) rename src/renderer/src/pages/settings/MCPSettings/{McpResourcesSection.tsx => McpMarketList.tsx} (79%) diff --git a/src/renderer/src/pages/settings/MCPSettings/BuiltinMCPServersSection.tsx b/src/renderer/src/pages/settings/MCPSettings/BuiltinMCPServerList.tsx similarity index 98% rename from src/renderer/src/pages/settings/MCPSettings/BuiltinMCPServersSection.tsx rename to src/renderer/src/pages/settings/MCPSettings/BuiltinMCPServerList.tsx index 5fc6b63c9d..249d514249 100644 --- a/src/renderer/src/pages/settings/MCPSettings/BuiltinMCPServersSection.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/BuiltinMCPServerList.tsx @@ -9,7 +9,7 @@ import styled from 'styled-components' import { SettingTitle } from '..' -const BuiltinMCPServersSection: FC = () => { +const BuiltinMCPServerList: FC = () => { const { t } = useTranslation() const { addMCPServer, mcpServers } = useMCPServers() @@ -176,4 +176,4 @@ const ServerFooter = styled.div` margin-top: 10px; ` -export default BuiltinMCPServersSection +export default BuiltinMCPServerList diff --git a/src/renderer/src/pages/settings/MCPSettings/McpResourcesSection.tsx b/src/renderer/src/pages/settings/MCPSettings/McpMarketList.tsx similarity index 79% rename from src/renderer/src/pages/settings/MCPSettings/McpResourcesSection.tsx rename to src/renderer/src/pages/settings/MCPSettings/McpMarketList.tsx index 9f0c2f583c..7c61a74dbe 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpResourcesSection.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpMarketList.tsx @@ -5,7 +5,7 @@ import styled from 'styled-components' import { SettingTitle } from '..' -const mcpResources = [ +const mcpMarkets = [ { name: 'modelscope.cn', url: 'https://www.modelscope.cn/mcp', @@ -62,38 +62,38 @@ const mcpResources = [ } ] -const McpResourcesSection: FC = () => { +const McpMarketList: FC = () => { const { t } = useTranslation() return ( <> {t('settings.mcp.findMore')} - - {mcpResources.map((resource) => ( - window.open(resource.url, '_blank', 'noopener,noreferrer')}> - - - {resource.name} + + {mcpMarkets.map((resource) => ( + window.open(resource.url, '_blank', 'noopener,noreferrer')}> + + + {resource.name} - - {t(resource.descriptionKey)} - + + {t(resource.descriptionKey)} + ))} - + ) } -const ResourcesGrid = styled.div` +const MarketGrid = styled.div` display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; margin-bottom: 20px; ` -const ResourceCard = styled.div` +const MarketCard = styled.div` display: flex; flex-direction: column; border: 0.5px solid var(--color-border); @@ -110,13 +110,13 @@ const ResourceCard = styled.div` } ` -const ResourceHeader = styled.div` +const MarketHeader = styled.div` display: flex; align-items: center; margin-bottom: 8px; ` -const ResourceLogo = styled.img` +const MarketLogo = styled.img` width: 20px; height: 20px; border-radius: 4px; @@ -124,7 +124,7 @@ const ResourceLogo = styled.img` margin-right: 8px; ` -const ResourceName = styled.span` +const MarketName = styled.span` font-size: 14px; font-weight: 500; flex: 1; @@ -139,7 +139,7 @@ const ExternalLinkIcon = styled.div` align-items: center; ` -const ResourceDescription = styled.div` +const MarketDescription = styled.div` font-size: 12px; color: var(--color-text-2); overflow: hidden; @@ -149,4 +149,4 @@ const ResourceDescription = styled.div` line-height: 1.4; ` -export default McpResourcesSection +export default McpMarketList diff --git a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx index 151579ad03..e8c030188f 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx @@ -15,10 +15,10 @@ import styled from 'styled-components' import { SettingTitle } from '..' import AddMcpServerModal from './AddMcpServerModal' -import BuiltinMCPServersSection from './BuiltinMCPServersSection' +import BuiltinMCPServerList from './BuiltinMCPServerList' import EditMcpJsonPopup from './EditMcpJsonPopup' import InstallNpxUv from './InstallNpxUv' -import McpResourcesSection from './McpResourcesSection' +import McpMarketList from './McpMarketList' import SyncServersPopup from './SyncServersPopup' const McpServersList: FC = () => { @@ -248,8 +248,8 @@ const McpServersList: FC = () => { /> )} - - + + Date: Sun, 17 Aug 2025 18:46:33 +0800 Subject: [PATCH 11/42] test: add tests for rehypeScalableSvg (#9248) --- package.json | 2 + .../__tests__/rehypeScalableSvg.test.ts | 337 ++++++++++++++++++ .../Markdown/plugins/rehypeScalableSvg.ts | 13 +- yarn.lock | 26 +- 4 files changed, 370 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/pages/home/Markdown/plugins/__tests__/rehypeScalableSvg.test.ts diff --git a/package.json b/package.json index a824984845..6b974bb8a2 100644 --- a/package.json +++ b/package.json @@ -243,7 +243,9 @@ "reflect-metadata": "0.2.2", "rehype-katex": "^7.0.1", "rehype-mathjax": "^7.1.0", + "rehype-parse": "^9.0.1", "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", "remark-cjk-friendly": "^1.2.0", "remark-gfm": "^4.0.1", "remark-github-blockquote-alert": "^2.0.0", diff --git a/src/renderer/src/pages/home/Markdown/plugins/__tests__/rehypeScalableSvg.test.ts b/src/renderer/src/pages/home/Markdown/plugins/__tests__/rehypeScalableSvg.test.ts new file mode 100644 index 0000000000..c74e69d5f0 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/__tests__/rehypeScalableSvg.test.ts @@ -0,0 +1,337 @@ +import rehypeParse from 'rehype-parse' +import rehypeStringify from 'rehype-stringify' +import { unified } from 'unified' +import { describe, expect, it } from 'vitest' + +import rehypeScalableSvg from '../rehypeScalableSvg' + +const processHtml = (html: string): string => { + return unified() + .use(rehypeParse, { fragment: true }) + .use(rehypeScalableSvg) + .use(rehypeStringify) + .processSync(html) + .toString() +} + +const createSvgHtml = (attributes: Record): string => { + const attrs = Object.entries(attributes) + .map(([key, value]) => `${key}="${value}"`) + .join(' ') + return `` +} + +describe('rehypeScalableSvg', () => { + describe('simple SVG cases', () => { + it('should add viewBox when missing numeric width and height', () => { + const html = createSvgHtml({ width: '100', height: '50' }) + const result = processHtml(html) + + expect(result).toContain('viewBox="0 0 100 50"') + expect(result).toContain('width="100%"') + expect(result).not.toContain('height=') + expect(result).toContain('max-width: 100') + }) + + it('should preserve existing viewBox and original dimensions', () => { + const html = createSvgHtml({ width: '100', height: '50', viewBox: '0 0 100 50' }) + const result = processHtml(html) + + expect(result).toContain('viewBox="0 0 100 50"') + expect(result).toContain('width="100"') + expect(result).toContain('height="50"') + expect(result).toContain('max-width: 100') + }) + + it('should handle different viewBox values and preserve original dimensions', () => { + const html = createSvgHtml({ width: '200', height: '100', viewBox: '10 20 180 80' }) + const result = processHtml(html) + + expect(result).toContain('viewBox="10 20 180 80"') + expect(result).toContain('width="200"') + expect(result).toContain('height="100"') + expect(result).toContain('max-width: 200') + }) + + it('should handle numeric width and height as strings', () => { + const html = createSvgHtml({ width: '300', height: '150' }) + const result = processHtml(html) + + expect(result).toContain('viewBox="0 0 300 150"') + expect(result).toContain('width="100%"') + expect(result).not.toContain('height=') + expect(result).toContain('max-width: 300') + }) + + it('should handle decimal numeric values', () => { + const html = createSvgHtml({ width: '100.5', height: '50.25' }) + const result = processHtml(html) + + expect(result).toContain('viewBox="0 0 100.5 50.25"') + expect(result).toContain('width="100%"') + expect(result).not.toContain('height=') + expect(result).toContain('max-width: 100.5') + }) + }) + + describe('complex SVG cases', () => { + it('should flag SVGs with units for runtime measurement', () => { + const html = createSvgHtml({ width: '100px', height: '50px' }) + const result = processHtml(html) + + expect(result).toContain('data-needs-measurement="true"') + expect(result).toContain('width="100px"') + expect(result).toContain('height="50px"') + expect(result).toContain('max-width: 100px') + expect(result).not.toContain('viewBox=') + }) + + it('should handle various CSS units', () => { + const units = ['px', 'pt', 'em', 'rem', '%', 'cm', 'mm'] + + units.forEach((unit) => { + const html = createSvgHtml({ width: `100${unit}`, height: `50${unit}` }) + const result = processHtml(html) + + expect(result).toContain('data-needs-measurement="true"') + expect(result).toContain(`width="100${unit}"`) + expect(result).toContain(`height="50${unit}"`) + expect(result).toContain(`max-width: 100${unit}`) + expect(result).not.toContain('viewBox=') + }) + }) + + it('should handle mixed unit types', () => { + const html = createSvgHtml({ width: '100px', height: '2em' }) + const result = processHtml(html) + + expect(result).toContain('data-needs-measurement="true"') + expect(result).toContain('width="100px"') + expect(result).toContain('height="2em"') + expect(result).toContain('max-width: 100px') + expect(result).not.toContain('viewBox=') + }) + + it('should handle SVGs with only width (no height)', () => { + const html = createSvgHtml({ width: '100px' }) + const result = processHtml(html) + + expect(result).not.toContain('data-needs-measurement="true"') + expect(result).toContain('width="100px"') + expect(result).toContain('max-width: 100px') + expect(result).not.toContain('viewBox=') + }) + + it('should handle SVGs with only height (no width)', () => { + const html = createSvgHtml({ height: '50px' }) + const result = processHtml(html) + + expect(result).not.toContain('data-needs-measurement="true"') + expect(result).toContain('height="50px"') + expect(result).not.toContain('max-width:') + expect(result).not.toContain('viewBox=') + }) + }) + + describe('edge cases', () => { + it('should handle SVG with no properties object', () => { + // Create HTML that will result in an SVG element with no properties + const html = '' + const result = processHtml(html) + + // The plugin should handle undefined properties gracefully + expect(result).toBe('') + }) + + it('should handle SVG with no dimensions', () => { + const html = '' + const result = processHtml(html) + + expect(result).not.toContain('width="') + expect(result).not.toContain('height=') + expect(result).not.toContain('viewBox=') + expect(result).not.toContain('data-needs-measurement="true"') + expect(result).not.toContain('max-width:') + }) + + it('should handle SVG with whitespace-only dimensions', () => { + const html = createSvgHtml({ width: ' ', height: ' ' }) + const result = processHtml(html) + + expect(result).not.toContain('data-needs-measurement="true"') + expect(result).toContain('width=" "') + expect(result).toContain('height=" "') + expect(result).not.toContain('max-width:') + expect(result).not.toContain('viewBox=') + }) + + it('should handle SVG with non-numeric strings', () => { + const html = createSvgHtml({ width: 'auto', height: 'inherit' }) + const result = processHtml(html) + + expect(result).toContain('data-needs-measurement="true"') + expect(result).toContain('width="auto"') + expect(result).toContain('height="inherit"') + expect(result).toContain('max-width: auto') + expect(result).not.toContain('viewBox=') + }) + + it('should handle SVG with mixed numeric and non-numeric values', () => { + const html = createSvgHtml({ width: '100', height: 'auto' }) + const result = processHtml(html) + + expect(result).toContain('data-needs-measurement="true"') + expect(result).toContain('width="100"') + expect(result).toContain('height="auto"') + expect(result).toContain('max-width: 100') + expect(result).not.toContain('viewBox=') + }) + }) + + describe('style handling', () => { + it('should append to existing style attribute for simple SVG', () => { + const html = createSvgHtml({ + width: '100', + height: '50', + style: 'fill: red; stroke: blue' + }) + const result = processHtml(html) + + expect(result).toContain('style="fill: red; stroke: blue; max-width: 100"') + expect(result).toContain('viewBox="0 0 100 50"') + expect(result).toContain('width="100%"') + }) + + it('should handle style attribute with trailing semicolon for simple SVG', () => { + const html = createSvgHtml({ + width: '100', + height: '50', + style: 'fill: red;' + }) + const result = processHtml(html) + + expect(result).toContain('style="fill: red; max-width: 100"') + expect(result).toContain('viewBox="0 0 100 50"') + }) + + it('should handle empty style attribute for simple SVG', () => { + const html = createSvgHtml({ + width: '100', + height: '50', + style: '' + }) + const result = processHtml(html) + + expect(result).toContain('style="max-width: 100"') + expect(result).toContain('viewBox="0 0 100 50"') + }) + + it('should handle style with only whitespace for simple SVG', () => { + const html = createSvgHtml({ + width: '100', + height: '50', + style: ' ' + }) + const result = processHtml(html) + + expect(result).toContain('style="max-width: 100"') + expect(result).toContain('viewBox="0 0 100 50"') + }) + + it('should preserve complex style attributes for complex SVG', () => { + const html = createSvgHtml({ + width: '100px', + height: '50px', + style: 'fill: url(#gradient); stroke: #333; stroke-width: 2;' + }) + const result = processHtml(html) + + expect(result).toContain('style="fill: url(#gradient); stroke: #333; stroke-width: 2; max-width: 100px"') + expect(result).toContain('data-needs-measurement="true"') + expect(result).toContain('width="100px"') + expect(result).toContain('height="50px"') + }) + }) + + describe('HTML structure handling', () => { + it('should only process SVG elements', () => { + const html = '
' + const result = processHtml(html) + + expect(result).toBe('
') + }) + + it('should process multiple SVG elements in one document', () => { + const html = ` + + + + ` + const result = processHtml(html) + + expect(result).toContain('viewBox="0 0 100 50"') + expect(result).toContain('data-needs-measurement="true"') + expect(result).toContain('viewBox="0 0 300 150"') + }) + + it('should handle nested SVG elements', () => { + const html = ` + + + + ` + const result = processHtml(html) + + expect(result).toContain('viewBox="0 0 200 200"') + expect(result).toContain('viewBox="0 0 100 100"') + }) + + it('should handle SVG with other attributes', () => { + const html = createSvgHtml({ + width: '100', + height: '50', + id: 'test-svg', + class: 'svg-class', + 'data-custom': 'value' + }) + const result = processHtml(html) + + expect(result).toContain('id="test-svg"') + expect(result).toContain('class="svg-class"') + expect(result).toContain('data-custom="value"') + expect(result).toContain('viewBox="0 0 100 50"') + expect(result).toContain('width="100%"') + }) + }) + + describe('numeric validation', () => { + it('should correctly identify numeric strings', () => { + const testCases = [ + { value: '100', expected: true }, + { value: '0', expected: true }, + { value: '-50', expected: true }, + { value: '3.14', expected: true }, + { value: '100px', expected: false }, + { value: 'auto', expected: false }, + { value: '', expected: false }, + { value: ' ', expected: false }, + { value: '100 ', expected: true }, + { value: ' 100', expected: true }, + { value: ' 100 ', expected: true } + ] + + testCases.forEach(({ value, expected }) => { + const html = createSvgHtml({ width: value, height: '50' }) + const result = processHtml(html) + + if (expected && value.trim() !== '') { + expect(result).toContain('viewBox="0 0 ' + value.trim() + ' 50"') + } else if (value.trim() === '') { + expect(result).not.toContain('viewBox=') + } else { + expect(result).toContain('data-needs-measurement="true"') + } + }) + }) + }) +}) diff --git a/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts b/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts index 535075c4dd..8d9d3e9332 100644 --- a/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts +++ b/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts @@ -30,10 +30,10 @@ function rehypeScalableSvg() { return (tree: Root) => { visit(tree, 'element', (node: Element) => { if (node.tagName === 'svg') { - const properties = node.properties || {} + const properties = node.properties const hasViewBox = 'viewBox' in properties - const width = properties.width as string | undefined - const height = properties.height as string | undefined + const width = (properties.width as string)?.trim() + const height = (properties.height as string)?.trim() // 1. Universally set max-width from the width attribute if it exists. // This is safe for both simple and complex cases. @@ -46,16 +46,15 @@ function rehypeScalableSvg() { // 2. Handle viewBox creation for simple, numeric cases. if (!hasViewBox && isNumeric(width) && isNumeric(height)) { properties.viewBox = `0 0 ${width} ${height}` + // Reset or clean up attributes. + properties.width = '100%' + delete properties.height } // 3. Flag complex cases for runtime measurement. else if (!hasViewBox && width && height) { properties['data-needs-measurement'] = 'true' } - // 4. Reset or clean up attributes. - properties.width = '100%' - delete properties.height - node.properties = properties } }) diff --git a/yarn.lock b/yarn.lock index 276a523034..1682a91c84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8621,7 +8621,9 @@ __metadata: reflect-metadata: "npm:0.2.2" rehype-katex: "npm:^7.0.1" rehype-mathjax: "npm:^7.1.0" + rehype-parse: "npm:^9.0.1" rehype-raw: "npm:^7.0.0" + rehype-stringify: "npm:^10.0.1" remark-cjk-friendly: "npm:^1.2.0" remark-gfm: "npm:^4.0.1" remark-github-blockquote-alert: "npm:^2.0.0" @@ -13728,7 +13730,7 @@ __metadata: languageName: node linkType: hard -"hast-util-to-html@npm:^9.0.5": +"hast-util-to-html@npm:^9.0.0, hast-util-to-html@npm:^9.0.5": version: 9.0.5 resolution: "hast-util-to-html@npm:9.0.5" dependencies: @@ -19509,6 +19511,17 @@ __metadata: languageName: node linkType: hard +"rehype-parse@npm:^9.0.1": + version: 9.0.1 + resolution: "rehype-parse@npm:9.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-from-html: "npm:^2.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/efa9ca17673fe70e2d322a1d262796bbed5f6a89382f8f8393352bbd6f6bbf1d4d1d050984b86ff9cb6c0fa2535175ab0829e53c94b1e38fc3c158e6c0ad90bc + languageName: node + linkType: hard + "rehype-raw@npm:^7.0.0": version: 7.0.0 resolution: "rehype-raw@npm:7.0.0" @@ -19520,6 +19533,17 @@ __metadata: languageName: node linkType: hard +"rehype-stringify@npm:^10.0.1": + version: 10.0.1 + resolution: "rehype-stringify@npm:10.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-to-html: "npm:^9.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/c643ae3a4862465033e0f1e9f664433767279b4ee9296570746970a79940417ec1fb1997a513659aab97063cf971c5d97e0af8129f590719f01628c8aa480765 + languageName: node + linkType: hard + "remark-cjk-friendly@npm:^1.2.0": version: 1.2.0 resolution: "remark-cjk-friendly@npm:1.2.0" From 33ec5c5c6b6dd754c139313dd922ec1632fe5a1e Mon Sep 17 00:00:00 2001 From: one Date: Sun, 17 Aug 2025 19:42:40 +0800 Subject: [PATCH 12/42] refactor: improve html artifact style (#9242) * refactor: use code font family in HtmlArtifactsCard * fix: pass onSave to HtmlArtifactsPopup * feat: add a save button * fix: avoid extra blank lines * feat: make split view resizable * refactor: improve streaming check, simplify Markdown component * refactor: improve button style and icons * test: update snapshots, add tests * refactor: move font family to TerminalPreview * test: update * refactor: add explicit type for Node * refactor: remove min-height * fix: type * refactor: improve scrollbar and splitter style --- src/renderer/src/assets/styles/ant.scss | 25 +++ .../CodeBlockView/HtmlArtifactsCard.tsx | 110 ++-------- .../CodeBlockView/HtmlArtifactsPopup.tsx | 206 ++++++++++-------- .../src/components/CodeBlockView/index.ts | 1 + .../src/components/CodeBlockView/view.tsx | 8 +- src/renderer/src/context/AntdProvider.tsx | 5 + .../src/pages/home/Markdown/CodeBlock.tsx | 51 ++++- src/renderer/src/pages/home/Markdown/Link.tsx | 3 +- .../src/pages/home/Markdown/Markdown.tsx | 20 +- .../src/pages/home/Markdown/Table.tsx | 3 +- .../Markdown/__tests__/CodeBlock.test.tsx | 147 +++++++++++++ .../home/Markdown/__tests__/Markdown.test.tsx | 29 +-- .../home/Markdown/__tests__/Table.test.tsx | 4 +- .../__snapshots__/CodeBlock.test.tsx.snap | 16 ++ .../__snapshots__/Markdown.test.tsx.snap | 7 +- .../src/utils/__tests__/markdown.test.ts | 4 +- src/renderer/src/utils/markdown.ts | 54 +++-- 17 files changed, 414 insertions(+), 279 deletions(-) create mode 100644 src/renderer/src/pages/home/Markdown/__tests__/CodeBlock.test.tsx create mode 100644 src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CodeBlock.test.tsx.snap diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index 7a2a2ce271..9ebc658010 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -184,3 +184,28 @@ box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important; } } + +.ant-splitter-bar { + .ant-splitter-bar-dragger { + &::before { + background-color: var(--color-border) !important; + transition: + background-color 0.15s ease, + width 0.15s ease; + } + &:hover { + &::before { + width: 4px !important; + background-color: var(--color-primary) !important; + transition-delay: 0.15s; + } + } + } + + .ant-splitter-bar-dragger-active { + &::before { + width: 4px !important; + background-color: var(--color-primary) !important; + } + } +} diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx index 67b583e9e6..3a82db90fa 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx @@ -1,11 +1,11 @@ -import { CodeOutlined, LinkOutlined } from '@ant-design/icons' +import { CodeOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { useTheme } from '@renderer/context/ThemeProvider' import { ThemeMode } from '@renderer/types' import { extractTitle } from '@renderer/utils/formats' import { Button } from 'antd' -import { Code, Download, Globe, Sparkles } from 'lucide-react' -import { FC, useMemo, useState } from 'react' +import { Code, DownloadIcon, Globe, LinkIcon, Sparkles } from 'lucide-react' +import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' import { ClipLoader } from 'react-spinners' import styled, { keyframes } from 'styled-components' @@ -14,92 +14,10 @@ import HtmlArtifactsPopup from './HtmlArtifactsPopup' const logger = loggerService.withContext('HtmlArtifactsCard') -const HTML_VOID_ELEMENTS = new Set([ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'link', - 'meta', - 'param', - 'source', - 'track', - 'wbr' -]) - -const HTML_COMPLETION_PATTERNS = [ - /<\/html\s*>/i, - //i, - /<\/div\s*>/i, - /<\/script\s*>/i, - /<\/style\s*>/i -] - interface Props { html: string -} - -function hasUnmatchedTags(html: string): boolean { - const stack: string[] = [] - const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g - let match - - while ((match = tagRegex.exec(html)) !== null) { - const [fullTag, tagName] = match - const isClosing = fullTag.startsWith('') || HTML_VOID_ELEMENTS.has(tagName.toLowerCase()) - - if (isSelfClosing) continue - - if (isClosing) { - if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) { - return true - } - } else { - stack.push(tagName.toLowerCase()) - } - } - - return stack.length > 0 -} - -function checkIsStreaming(html: string): boolean { - if (!html?.trim()) return false - - const trimmed = html.trim() - - // 快速检查:如果有明显的完成标志,直接返回false - for (const pattern of HTML_COMPLETION_PATTERNS) { - if (pattern.test(trimmed)) { - // 特殊情况:同时有DOCTYPE和 - if (trimmed.includes('/i.test(trimmed)) { - return false - } - // 如果只是以结尾,也认为是完成的 - if (/<\/html\s*>$/i.test(trimmed)) { - return false - } - } - } - - // 检查未完成的标志 - const hasIncompleteTag = /<[^>]*$/.test(trimmed) - const hasUnmatched = hasUnmatchedTags(trimmed) - - if (hasIncompleteTag || hasUnmatched) return true - - // 对于简单片段,如果长度较短且没有明显结束标志,可能还在生成 - const hasStructureTags = /<(html|body|head)[^>]*>/i.test(trimmed) - if (!hasStructureTags && trimmed.length < 500) { - return !HTML_COMPLETION_PATTERNS.some((pattern) => pattern.test(trimmed)) - } - - return false + onSave?: (html: string) => void + isStreaming?: boolean } const getTerminalStyles = (theme: ThemeMode) => ({ @@ -108,7 +26,7 @@ const getTerminalStyles = (theme: ThemeMode) => ({ promptColor: theme === 'dark' ? '#00ff00' : '#007700' }) -const HtmlArtifactsCard: FC = ({ html }) => { +const HtmlArtifactsCard: FC = ({ html, onSave, isStreaming = false }) => { const { t } = useTranslation() const title = extractTitle(html) || 'HTML Artifacts' const [isPopupOpen, setIsPopupOpen] = useState(false) @@ -116,7 +34,6 @@ const HtmlArtifactsCard: FC = ({ html }) => { const htmlContent = html || '' const hasContent = htmlContent.trim().length > 0 - const isStreaming = useMemo(() => checkIsStreaming(htmlContent), [htmlContent]) const handleOpenExternal = async () => { const path = await window.api.file.createTempFile('artifacts-preview.html') @@ -181,10 +98,10 @@ const HtmlArtifactsCard: FC = ({ html }) => { - - @@ -192,7 +109,13 @@ const HtmlArtifactsCard: FC = ({ html }) => { - setIsPopupOpen(false)} /> + setIsPopupOpen(false)} + /> ) } @@ -286,7 +209,6 @@ const ButtonContainer = styled.div` margin: 10px 16px !important; display: flex; flex-direction: row; - gap: 8px; ` const TerminalPreview = styled.div<{ $theme: ThemeMode }>` @@ -294,7 +216,7 @@ const TerminalPreview = styled.div<{ $theme: ThemeMode }>` background: ${(props) => getTerminalStyles(props.$theme).background}; border-radius: 8px; overflow: hidden; - font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; + font-family: var(--code-font-family); ` const TerminalContent = styled.div<{ $theme: ThemeMode }>` diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index dba34f29a7..a548d5e163 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -1,8 +1,8 @@ -import CodeEditor from '@renderer/components/CodeEditor' +import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor' import { isLinux, isMac, isWin } from '@renderer/config/constant' import { classNames } from '@renderer/utils' -import { Button, Modal } from 'antd' -import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react' +import { Button, Modal, Splitter, Tooltip } from 'antd' +import { Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -11,60 +11,17 @@ interface HtmlArtifactsPopupProps { open: boolean title: string html: string + onSave?: (html: string) => void onClose: () => void } type ViewMode = 'split' | 'code' | 'preview' -const HtmlArtifactsPopup: React.FC = ({ open, title, html, onClose }) => { +const HtmlArtifactsPopup: React.FC = ({ open, title, html, onSave, onClose }) => { const { t } = useTranslation() const [viewMode, setViewMode] = useState('split') - const [currentHtml, setCurrentHtml] = useState(html) const [isFullscreen, setIsFullscreen] = useState(false) - - // Preview refresh related state - const [previewHtml, setPreviewHtml] = useState(html) - const intervalRef = useRef(null) - const latestHtmlRef = useRef(html) - const currentPreviewHtmlRef = useRef(html) - - // Sync internal state when external html updates - useEffect(() => { - setCurrentHtml(html) - latestHtmlRef.current = html - }, [html]) - - // Update reference when internally edited html changes - useEffect(() => { - latestHtmlRef.current = currentHtml - }, [currentHtml]) - - // Update reference when preview content changes - useEffect(() => { - currentPreviewHtmlRef.current = previewHtml - }, [previewHtml]) - - // Check and refresh preview every 2 seconds (only when content changes) - useEffect(() => { - if (!open) return - - // Set initial preview content immediately - setPreviewHtml(latestHtmlRef.current) - - // Set timer to check for content changes every 2 seconds - intervalRef.current = setInterval(() => { - if (latestHtmlRef.current !== currentPreviewHtmlRef.current) { - setPreviewHtml(latestHtmlRef.current) - } - }, 2000) - - // Cleanup function - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current) - } - } - }, [open]) + const codeEditorRef = useRef(null) // Prevent body scroll when fullscreen useEffect(() => { @@ -79,8 +36,9 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht } }, [isFullscreen, open]) - const showCode = viewMode === 'split' || viewMode === 'code' - const showPreview = viewMode === 'split' || viewMode === 'preview' + const handleSave = () => { + codeEditorRef.current?.save?.() + } const renderHeader = () => ( setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}> @@ -93,7 +51,7 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht } + icon={} onClick={() => setViewMode('split')}> {t('html_artifacts.split')} @@ -107,7 +65,7 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht } + icon={} onClick={() => setViewMode('preview')}> {t('html_artifacts.preview')} @@ -126,6 +84,75 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht ) + const renderContent = () => { + const codePanel = ( + + + + +