diff --git a/bun.lock b/bun.lock index 30437ba..b81a522 100644 --- a/bun.lock +++ b/bun.lock @@ -86,6 +86,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.13.1", + "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", @@ -1194,12 +1195,28 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "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=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], @@ -1218,6 +1235,20 @@ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.2", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], @@ -1444,10 +1475,14 @@ "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1728,6 +1763,8 @@ "lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], diff --git a/docs/images/personal-agent-markdown-gfm.png b/docs/images/personal-agent-markdown-gfm.png new file mode 100644 index 0000000..b23f372 Binary files /dev/null and b/docs/images/personal-agent-markdown-gfm.png differ diff --git a/frontend/package.json b/frontend/package.json index 7ee512a..593edea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -54,6 +54,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.13.1", + "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", diff --git a/frontend/src/index.css b/frontend/src/index.css index be07212..6cfdfe5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -645,6 +645,23 @@ overflow-x: auto; } .md-content pre code { background: none; padding: 0; } +.md-content .md-table-wrapper { + max-width: 100%; + margin: 12px 0; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 8px; +} +.md-content table { width: 100%; border-collapse: collapse; } +.md-content th, .md-content td { + min-width: 8rem; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +.md-content th { background: var(--secondary); font-weight: 600; } +.md-content th + th, .md-content td + td { border-left: 1px solid var(--border); } +.md-content tbody tr:last-child td { border-bottom: none; } .md-content ul, .md-content ol { margin: 6px 0; padding-left: 20px; } .md-content li { margin: 2px 0; } .md-content h1, .md-content h2, .md-content h3 { margin: 12px 0 6px; font-weight: 600; } diff --git a/frontend/src/pages/PersonalAgent.tsx b/frontend/src/pages/PersonalAgent.tsx index 4808628..24913ba 100644 --- a/frontend/src/pages/PersonalAgent.tsx +++ b/frontend/src/pages/PersonalAgent.tsx @@ -1,6 +1,7 @@ -import { type DragEvent, type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { type DragEvent, type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react"; import * as Dialog from "@radix-ui/react-dialog"; -import ReactMarkdown from "react-markdown"; +import ReactMarkdown, { type Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; import { BookOpen, Brain, @@ -40,6 +41,45 @@ import { const PAGE_CLASS = "flex-1 w-full max-w-[1800px] mx-auto px-4 py-5 md:px-7 md:py-6 xl:px-8"; const ACCEPTED_DOCUMENTS = ".pdf,.docx,.pptx,.xlsx,.txt,.md,.markdown,.csv,.tsv,.json,.yaml,.yml,.xml,.html,.htm,.rtf,.log,.py,.js,.jsx,.ts,.tsx,.java,.go,.rs,.sql,.css,.sh"; +const MARKDOWN_COMPONENTS: Components = { + table: ({ children }) => ( +
+ {children}
+
+ ), +}; + +function MessageMarkdown({ content }: { content: string }) { + const messageId = useId(); + const clobberPrefix = `user-content-agent-${messageId}-`; + const footnoteLabelId = `${clobberPrefix}footnote-label`; + // clobberPrefix 隔离脚注及回链;上游固定的 footnote-label 和 aria-describedby 需单独隔离。 + // + // clobberPrefix scopes footnotes and backlinks; the upstream fixed footnote-label and aria-describedby need separate scoping. + const components = useMemo(() => ({ + ...MARKDOWN_COMPONENTS, + h2: ({ node, ...props }) => ( +

+ ), + a: ({ node, ...props }) => ( + + ), + }), [footnoteLabelId]); + + return ( + + {content} + + ); +} + const STARTERS = [ { icon: Brain, text: "根据我的画像,告诉我现在最该补什么" }, { icon: BookOpen, text: "结合我的错题,帮我安排一周复习计划" }, @@ -502,7 +542,7 @@ export default function PersonalAgent() {
- {message.content} +
{!!message.sources?.length && (
diff --git a/frontend/tests/personal-agent-markdown.test.tsx b/frontend/tests/personal-agent-markdown.test.tsx new file mode 100644 index 0000000..3809544 --- /dev/null +++ b/frontend/tests/personal-agent-markdown.test.tsx @@ -0,0 +1,304 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { GlobalWindow } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import type { AgentMessage, ConversationDetail } from "../src/api/personalAgent"; + +const conversationId = "markdown-regression"; +const conversationTitle = "Markdown 回归会话"; +const timestamp = "2026-01-01T00:00:00.000Z"; +const tableMarkdown = [ + "| 能力方向 | 当前重点 | 下一步 |", + "| --- | --- | --- |", + "| TypeScript | **联合类型** | 编写类型守卫 |", + "| React | 状态管理 | 练习 `useState` |", +].join("\n"); + +let browser: GlobalWindow; +let host: HTMLDivElement; +let root: Root | undefined; +let historyContent: string; +let replyContent: string; +let messages: AgentMessage[]; +let requests: string[]; +let sentMessages: Array<{ message: string; conversation_id: string | null }>; +let unexpectedRequests: string[]; +let originalFetch: typeof fetch; +const originals = new Map(); + +const json = (data: unknown) => new Response(JSON.stringify(data), { + headers: { "Content-Type": "application/json" }, +}); + +beforeEach(() => { + // GlobalWindow 复用宿主构造器,避免 Bun 的 VM 上下文缺少 SyntaxError;仍禁用页面脚本执行。 + // + // GlobalWindow supplies host constructors missing from Bun's VM context; page script evaluation stays disabled. + browser = new GlobalWindow({ + url: "http://localhost", + settings: { + enableJavaScriptEvaluation: false, + disableJavaScriptFileLoading: true, + disableCSSFileLoading: true, + navigation: { disableChildFrameNavigation: true }, + }, + }); + Object.defineProperty(browser.HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: () => {}, + }); + for (const [key, value] of Object.entries({ + window: browser, + document: browser.document, + navigator: browser.navigator, + localStorage: browser.localStorage, + HTMLElement: browser.HTMLElement, + Element: browser.Element, + Node: browser.Node, + MutationObserver: browser.MutationObserver, + getComputedStyle: browser.getComputedStyle.bind(browser), + requestAnimationFrame: browser.requestAnimationFrame.bind(browser), + cancelAnimationFrame: browser.cancelAnimationFrame.bind(browser), + IS_REACT_ACT_ENVIRONMENT: true, + })) { + originals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + } + host = document.createElement("div"); + document.body.append(host); + historyContent = `## 学习安排\n\n${tableMarkdown}`; + replyContent = `## 后续学习安排\n\n${tableMarkdown}`; + messages = []; + requests = []; + sentMessages = []; + unexpectedRequests = []; + originalFetch = globalThis.fetch; + + // 只允许约定的内存 API 路由;未知请求直接失败,绝不转发到真实服务。 + // + // Only the in-memory API routes below are allowed; unknown requests fail without forwarding. + globalThis.fetch = (async (input, options) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url, + "http://localhost"); + const method = (options?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + const request = `${method} ${url.pathname}`; + requests.push(request); + const summary = { + conversation_id: conversationId, + title: conversationTitle, + message_count: messages.length, + created_at: timestamp, + updated_at: timestamp, + }; + if (url.origin === "http://localhost") { + if (request === "GET /api/personal-agent/documents") { + return json({ items: [], supported_extensions: [".md"], max_upload_bytes: 20 * 1024 * 1024 }); + } + if (request === "GET /api/personal-agent/conversations") return json({ items: [summary] }); + if (request === `GET /api/personal-agent/conversations/${conversationId}`) { + return json({ ...summary, user_id: "markdown-test-user", messages } satisfies ConversationDetail); + } + if (request === "POST /api/personal-agent/chat") { + const payload = JSON.parse(String(options?.body)); + sentMessages.push(payload); + const reply: AgentMessage = { + role: "assistant", content: replyContent, created_at: timestamp, sources: [], + }; + messages.push({ role: "user", content: payload.message, created_at: timestamp }, reply); + return json({ conversation_id: conversationId, title: conversationTitle, message: reply }); + } + } + unexpectedRequests.push(request); + throw new Error(`Unexpected API request: ${request}`); + }) as typeof fetch; +}); + +afterEach(async () => { + try { + if (root) await act(async () => root!.unmount()); + await browser.happyDOM.close(); + } finally { + root = undefined; + globalThis.fetch = originalFetch; + for (const [key, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + originals.clear(); + } + expect(unexpectedRequests).toEqual([]); +}); + +async function settle() { + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); +} + +async function openHistory(initialMessages?: AgentMessage[]) { + messages = initialMessages ?? [ + { role: "user", content: "请整理学习计划", created_at: timestamp }, + { role: "assistant", content: historyContent, created_at: timestamp, sources: [] }, + ]; + // 独立运行时先安装 DOM,再加载真实页面;同进程中的 React DOM 可能已由其他测试加载。 + // + // Install the DOM before loading the real page; another test may already have loaded React DOM in this process. + const [{ default: PersonalAgent }, { createRoot }] = await Promise.all([ + import("../src/pages/PersonalAgent"), import("react-dom/client"), + ]); + root = createRoot(host); + await act(async () => { root!.render(); }); + await settle(); + const conversation = Array.from(host.querySelectorAll("button")) + .find((button) => button.textContent?.includes(conversationTitle)); + expect(conversation, "历史会话入口应由 mock API 加载").toBeDefined(); + await act(async () => { conversation!.click(); }); + await settle(); + expect(requests).toContain(`GET /api/personal-agent/conversations/${conversationId}`); + const userMessage = messages.find((message) => message.role === "user"); + if (userMessage) expect(host.textContent).toContain(userMessage.content); +} + +const texts = (parent: ParentNode, selector: string) => + Array.from(parent.querySelectorAll(selector), (element) => element.textContent); + +function expectThreeColumnTable() { + const tables = host.querySelectorAll("table"); + expect(tables).toHaveLength(1); + const table = tables[0]; + expect(table.closest(".md-table-wrapper")).not.toBeNull(); + expect(texts(table, "thead tr th")).toEqual(["能力方向", "当前重点", "下一步"]); + const rows = table.querySelectorAll("tbody tr"); + expect(rows).toHaveLength(2); + expect(texts(rows[0], "td")).toEqual(["TypeScript", "联合类型", "编写类型守卫"]); + expect(texts(rows[1], "td")).toEqual(["React", "状态管理", "练习 useState"]); + expect(table.querySelectorAll("tbody td")).toHaveLength(6); + expect(table.querySelector("td strong")?.textContent).toBe("联合类型"); + expect(table.querySelector("td code")?.textContent).toBe("useState"); +} + +test("历史消息将 GFM 表格渲染为三列表头和两行数据,并保留单元格内 Markdown", async () => { + await openHistory(); + expectThreeColumnTable(); +}); + +test("历史消息保留标题、列表、引用、强调、代码和安全链接", async () => { + historyContent = [ + "## 学习笔记", "", "**重点**、*提醒*,使用 `useMemo`。", "", + "- 阅读文档", "- 编写练习", "", "1. 复盘", "2. 调整", "", + "> 每天进步一点。", "", "```ts", "const ready = true;", "```", "", + "[学习资料](https://example.com/learning?step=1)", + ].join("\n"); + await openHistory(); + expect(texts(host, "h2")).toContain("学习笔记"); + expect(texts(host, "ul > li")).toEqual(["阅读文档", "编写练习"]); + expect(texts(host, "ol > li")).toEqual(["复盘", "调整"]); + expect(texts(host, "blockquote p")).toEqual(["每天进步一点。"]); + expect(texts(host, "strong")).toContain("重点"); + expect(texts(host, "em")).toContain("提醒"); + expect(texts(host, "code")).toContain("useMemo"); + expect(host.querySelector("pre > code.language-ts")?.textContent).toBe("const ready = true;\n"); + const link = Array.from(host.querySelectorAll("a")).find((element) => element.textContent === "学习资料"); + expect(link?.getAttribute("href")).toBe("https://example.com/learning?step=1"); +}); + +test("多条消息的脚注、重复引用、回链和无障碍描述仅关联本消息,重渲染后保持稳定", async () => { + await openHistory([1, 2].map((number) => ({ + role: "assistant", + content: `## 消息${number}\n\n内容[^1],再次引用[^1]。[普通链接](#footnote-label)\n\n[^1]: 注释${number}`, + created_at: timestamp, + sources: [], + }))); + + function expectIsolatedFootnotes() { + const ids = Array.from(document.querySelectorAll("[id]"), (element) => element.id); + expect(new Set(ids).size, "同页全部 ID 必须唯一").toBe(ids.length); + const blocks = Array.from(host.querySelectorAll(".md-content")); + expect(blocks).toHaveLength(2); + return blocks.map((block, index) => { + const heading = block.querySelector("h2:not(.sr-only)"); + expect(heading?.textContent).toBe(`消息${index + 1}`); + expect(heading?.hasAttribute("id")).toBe(false); + expect(block.querySelector('a:not([data-footnote-ref]):not([data-footnote-backref])') + ?.getAttribute("href")).toBe("#footnote-label"); + const section = block.querySelector("section[data-footnotes]"); + const label = section?.querySelector("h2"); + const definitions = Array.from(section?.querySelectorAll("li[id]") ?? []); + const references = Array.from(block.querySelectorAll("a[data-footnote-ref]")); + const backlinks = Array.from(block.querySelectorAll("a[data-footnote-backref]")); + expect(label?.id).toBeTruthy(); + expect(label?.classList.contains("sr-only")).toBe(true); + expect(definitions).toHaveLength(1); + expect(definitions[0].textContent).toContain(`注释${index + 1}`); + expect(references).toHaveLength(2); + expect(backlinks).toHaveLength(2); + for (const element of [label!, definitions[0], ...references]) { + expect(element.id.startsWith("user-content-")).toBe(true); + } + for (const [referenceIndex, reference] of references.entries()) { + const href = reference.getAttribute("href"); + expect(href?.startsWith("#")).toBe(true); + expect(document.getElementById(href!.slice(1))).toBe(definitions[0]); + const descriptions = reference.getAttribute("aria-describedby")?.split(/\s+/); + expect(descriptions).toEqual([label!.id]); + expect(document.getElementById(descriptions![0])).toBe(label); + const backlink = backlinks[referenceIndex]; + const backHref = backlink.getAttribute("href"); + expect(backHref?.startsWith("#")).toBe(true); + expect(document.getElementById(backHref!.slice(1))).toBe(reference); + } + expect(block.querySelector("[node]")).toBeNull(); + return block.innerHTML; + }); + } + + const initialMarkup = expectIsolatedFootnotes(); + const collapseLibrary = host.querySelector('button[aria-label="收起资料库"]'); + expect(collapseLibrary).not.toBeNull(); + await act(async () => { collapseLibrary!.click(); }); + expect(expectIsolatedFootnotes()).toEqual(initialMarkup); +}); + +test("历史消息不生成可执行的原始 HTML 或 javascript/data 危险链接", async () => { + historyContent = [ + "## 安全渲染", "", + '', "", + '', "", + '', "", + '原始链接', "", + "[危险脚本](javascript:alert%281%29)", "", + "[危险数据](data:text/html;base64,PHNjcmlwdD4=)", "", + "[安全链接](https://example.com/safe)", + ].join("\n"); + await openHistory(); + expect(texts(host, "h2")).toContain("安全渲染"); + // 排除活动 HTML 节点及危险 href,不能仅凭 happy-dom 禁用脚本就判定安全。 + // + // Exclude active HTML nodes and unsafe hrefs instead of relying only on happy-dom disabling scripts. + expect(host.querySelector("script, iframe, img, #raw-agent-html, [onerror], [onclick]")).toBeNull(); + expect(Reflect.get(browser, "__agentMarkdownExecuted")).toBeUndefined(); + expect(host.textContent).toContain("危险脚本"); + expect(host.textContent).toContain("危险数据"); + const unsafeLinks = Array.from(host.querySelectorAll("a[href]")) + .filter((link) => /^(?:javascript|vbscript|data):/i.test(link.getAttribute("href")!.trim())); + expect(unsafeLinks).toHaveLength(0); + const safeLink = Array.from(host.querySelectorAll("a")).find((link) => link.textContent === "安全链接"); + expect(safeLink?.getAttribute("href")).toBe("https://example.com/safe"); +}); + +test("继续聊点击建议问题后,新的 Agent 回复也渲染三列表格而非原始管道文本", async () => { + await openHistory([]); + expect(host.querySelectorAll("table")).toHaveLength(0); + // 通过建议问题的真实点击验证发送链路,避免 textarea 合成事件受 React DOM 模块加载顺序影响。 + // + // Exercise sending through a real starter click, avoiding synthesized textarea events sensitive to React DOM import order. + const question = "结合我的错题,帮我安排一周复习计划"; + const starter = Array.from(host.querySelectorAll("button")) + .find((button) => button.textContent === question); + expect(starter).toBeDefined(); + await act(async () => { starter!.click(); }); + await settle(); + expect(requests).toContain("POST /api/personal-agent/chat"); + expect(sentMessages).toEqual([{ message: question, conversation_id: conversationId }]); + expect(host.textContent).toContain(question); + expect(texts(host, "h2")).toContain("后续学习安排"); + expectThreeColumnTable(); +});