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 001/131] 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 170c4ca90..2c15302be 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 002/131] 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 dd046a57e..f4e31e891 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 003/131] 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 5fc6b63c9..249d51424 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 9f0c2f583..7c61a74db 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 151579ad0..e8c030188 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 004/131] 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 a82498484..6b974bb8a 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 000000000..c74e69d5f --- /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 535075c4d..8d9d3e933 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 276a52303..1682a91c8 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 005/131] 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 7a2a2ce27..9ebc65801 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 67b583e9e..3a82db90f 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 dba34f29a..a548d5e16 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 = ( + + + + +