diff --git a/bun.lock b/bun.lock index 753e10c4..eddc08db 100644 --- a/bun.lock +++ b/bun.lock @@ -190,6 +190,7 @@ "http-proxy-middleware": "^3.0.5", "katex": "^0.17.0", "marked": "^17.0.3", + "marked-linkify-it": "^4.0.2", "morphdom": "^2.7.7", "motion": "^12.23.24", "next-themes": "^0.4.6", @@ -2423,7 +2424,7 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], - "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "linkify-it": ["linkify-it@6.1.0", "", { "dependencies": { "uc.micro": "^3.0.0" } }, "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -2479,6 +2480,8 @@ "marked": ["marked@17.0.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A=="], + "marked-linkify-it": ["marked-linkify-it@4.0.2", "", { "dependencies": { "linkify-it": "^6.1.0" }, "peerDependencies": { "marked": ">=4 <19" } }, "sha512-3nvMW0MHU+ZNBhzSnqRTl+tCkUwIBbg1xbHx5mtJqCH8ieJGLJ0JzV36ESnnufSgZ0mSwO22fBIeNEu5vvYd9w=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -3241,7 +3244,7 @@ "typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="], - "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "uc.micro": ["uc.micro@3.0.0", "", {}, "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], @@ -3647,6 +3650,10 @@ "markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "markdown-it/linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + + "markdown-it/uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "micromark-extension-math/katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 2b224dcb..7009c011 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -66,6 +66,7 @@ "http-proxy-middleware": "^3.0.5", "katex": "^0.17.0", "marked": "^17.0.3", + "marked-linkify-it": "^4.0.2", "morphdom": "^2.7.7", "motion": "^12.23.24", "next-themes": "^0.4.6", diff --git a/packages/ui/src/components/chat/markdown/markdownCore.test.ts b/packages/ui/src/components/chat/markdown/markdownCore.test.ts index 968363d3..d946ae53 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.test.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.test.ts @@ -187,3 +187,30 @@ describe('Markdown images', () => { expect(html).not.toContain('data-openchamber-markdown-image'); }); }); + +describe('CJK-aware link parsing', () => { + const hrefOf = (html: string): string | null => /]*href="([^"]*)"/.exec(html)?.[1] ?? null; + + test('bare URL followed by a CJK annotation trims the annotation from the href', () => { + const html = renderMarkdownSync('访问 https://example.com/docs(中文说明)了解更多'); + expect(hrefOf(html)).toBe('https://example.com/docs'); + }); + + test('bare URL followed by CJK punctuation trims the punctuation', () => { + expect(hrefOf(renderMarkdownSync('地址 https://example.com/guide,详见'))).toBe( + 'https://example.com/guide', + ); + expect(hrefOf(renderMarkdownSync('官网 https://example.com。'))).toBe('https://example.com'); + }); + + test('correct links are unaffected', () => { + expect(hrefOf(renderMarkdownSync('官方文档见 [这里](https://docs.example.com)(中文说明)'))).toBe( + 'https://docs.example.com', + ); + expect(hrefOf(renderMarkdownSync('[下载](https://dl.example.com/安装包(正式版))'))).toBe( + 'https://dl.example.com/安装包(正式版)', + ); + expect(hrefOf(renderMarkdownSync('[a](url(1))'))).toBe('url(1)'); + expect(hrefOf(renderMarkdownSync('[a](url "title")'))).toBe('url'); + }); +}); diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index 2d3cb7a9..542986f5 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -1,4 +1,5 @@ import { Marked, marked, type Tokens } from 'marked'; +import markedLinkifyIt from 'marked-linkify-it'; import remend from 'remend'; import katex from 'katex'; import DOMPurify from 'dompurify'; @@ -321,10 +322,15 @@ const blockMathExtension = { }, }; -const createParser = (imageMode: MarkdownImageMode) => new Marked().use({ - gfm: true, - breaks: false, - extensions: [inlineMathExtension, blockMathExtension], +// marked's GFM autolink swallows CJK punctuation after a bare URL, so switch +// to marked-linkify-it, which treats Unicode punctuation as a URL boundary. +// Plain CJK characters right after a URL are still consumed, matching GitHub. +const createParser = (imageMode: MarkdownImageMode) => new Marked().use( + markedLinkifyIt({ fuzzyLink: false }), + { + gfm: true, + breaks: false, + extensions: [inlineMathExtension, blockMathExtension], renderer: { // Assistant output is untrusted. Markdown constructs still render as HTML, // but raw HTML must remain visible text so it cannot introduce active DOM