diff --git a/frontend/package.json b/frontend/package.json index 0da3c52..8aa07d0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "@douyinfe/semi-icons": "2.103.0", "@douyinfe/semi-ui-19": "2.103.0", "ai": "6.0.277", + "markstream-react": "2.0.8", "react": "19.2.8", "react-dom": "19.2.8" }, diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c8ff545..48edf7e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: ai: specifier: 6.0.277 version: 6.0.277(zod@4.5.4) + markstream-react: + specifier: 2.0.8 + version: 2.0.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -728,6 +731,10 @@ packages: electron-to-chromium@1.5.422: resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -916,6 +923,9 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + linkify-it@6.1.0: + resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==} + linkifyjs@4.3.3: resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} @@ -939,9 +949,56 @@ packages: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} + markdown-it-container@4.0.0: + resolution: {integrity: sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==} + + markdown-it-footnote@4.0.0: + resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==} + + markdown-it-ins@4.0.0: + resolution: {integrity: sha512-sWbjK2DprrkINE4oYDhHdCijGT+MIDhEupjSHLXe5UXeVr5qmVxs/nTUVtgi0Oh/qtF+QKV0tNWDhQBEPxiMew==} + + markdown-it-mark@4.0.0: + resolution: {integrity: sha512-YLhzaOsU9THO/cal0lUjfMjrqSMPjjyjChYM7oyj4DnyaXEzA8gnW6cVJeyCrCVeyesrY2PlEdUYJSPFYL4Nkg==} + + markdown-it-sup@2.0.0: + resolution: {integrity: sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA==} + + markdown-it-task-checkbox@1.0.6: + resolution: {integrity: sha512-7pxkHuvqTOu3iwVGmDPeYjQg+AIS9VQxzyLP9JCg9lBjgPAJXGEkChK6A2iFuj3tS0GV3HG2u5AMNhcQqwxpJw==} + + markdown-it-ts@1.1.2: + resolution: {integrity: sha512-17qO3LQLK+JXom5Zt6Z9l7OGWWv9eYquTxdj3xIPGe1OBHdYXrzNYA3xCgCyIj1uItv4jkqbQf6FkaqifKkySw==} + engines: {node: '>=20.19'} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + markstream-core@2.0.8: + resolution: {integrity: sha512-3VLe2fMDhoe0SnH5OXsmdXfT6vrsY8/WetNE02xR8AuTaP7ixH2tBl9dHTsLgiLMp/z8D+TB0xVP/tkLzkmNlA==} + + markstream-react@2.0.8: + resolution: {integrity: sha512-LRWCOkzvl4T4d1IWBAG3buJBnazNX1qmkND9ns66RGdF3un194CTXS9A/Qc0kZT7NvIcJvCsCiLoDJ42J1UAOw==} + peerDependencies: + '@antv/infographic': ^0.2.3 + '@terrastruct/d2': '>=0.1.33' + katex: '>=0.16.22' + mermaid: '>=11' + react: '>=18' + react-dom: '>=18' + stream-diffs: '>=0.0.2' + peerDependenciesMeta: + '@antv/infographic': + optional: true + '@terrastruct/d2': + optional: true + katex: + optional: true + mermaid: + optional: true + stream-diffs: + optional: true + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -990,6 +1047,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -1195,6 +1255,10 @@ packages: prosemirror-view@1.42.3: resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: @@ -1291,6 +1355,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stream-markdown-parser@1.2.14: + resolution: {integrity: sha512-7nZR7ZUM8uPmXGMZPcP3wPtb40Ce8NUJy6XDGmengRENxE824VD6o6dbPvosUVE07mxG58LgdL+VU7BeOSEvAQ==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -1327,6 +1394,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@3.0.0: + resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -2173,6 +2243,8 @@ snapshots: electron-to-chromium@1.5.422: {} + entities@8.1.0: {} + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -2360,6 +2432,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + linkify-it@6.1.0: + dependencies: + uc.micro: 3.0.0 + linkifyjs@4.3.3: {} lodash@4.18.1: {} @@ -2378,8 +2454,38 @@ snapshots: markdown-extensions@2.0.0: {} + markdown-it-container@4.0.0: {} + + markdown-it-footnote@4.0.0: {} + + markdown-it-ins@4.0.0: {} + + markdown-it-mark@4.0.0: {} + + markdown-it-sup@2.0.0: {} + + markdown-it-task-checkbox@1.0.6: {} + + markdown-it-ts@1.1.2: + dependencies: + entities: 8.1.0 + linkify-it: 6.1.0 + mdurl: 2.1.0 + punycode.js: 2.3.1 + markdown-table@3.0.4: {} + markstream-core@2.0.8: {} + + markstream-react@2.0.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@floating-ui/dom': 1.8.0 + clsx: 2.1.1 + markstream-core: 2.0.8 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + stream-markdown-parser: 1.2.14 + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -2543,6 +2649,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdurl@2.1.0: {} + memoize-one@5.2.1: {} micromark-core-commonmark@2.0.3: @@ -2931,6 +3039,8 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.1 + punycode.js@2.3.1: {} + react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 @@ -3078,6 +3188,16 @@ snapshots: space-separated-tokens@2.0.2: {} + stream-markdown-parser@1.2.14: + dependencies: + markdown-it-container: 4.0.0 + markdown-it-footnote: 4.0.0 + markdown-it-ins: 4.0.0 + markdown-it-mark: 4.0.0 + markdown-it-sup: 2.0.0 + markdown-it-task-checkbox: 1.0.6 + markdown-it-ts: 1.1.2 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -3112,6 +3232,8 @@ snapshots: typescript@5.9.3: {} + uc.micro@3.0.0: {} + undici-types@6.21.0: {} undici@6.28.1: {} diff --git a/frontend/src/ai/ChatPanel.tsx b/frontend/src/ai/ChatPanel.tsx index f4c9e4a..180921a 100644 --- a/frontend/src/ai/ChatPanel.tsx +++ b/frontend/src/ai/ChatPanel.tsx @@ -1,5 +1,13 @@ import { useChat } from "@ai-sdk/react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Banner, Button, @@ -35,6 +43,12 @@ import type { UIAction, } from "./types"; +const MessageMarkdown = lazy(() => + import("./MessageMarkdown").then((module) => ({ + default: module.MessageMarkdown, + })), +); + export function ChatPanel({ open, context, @@ -427,9 +441,25 @@ export function ChatPanel({ {message.parts.map((part, index) => part.type === "text" ? ( -
- {part.text} -
+ message.role === "assistant" ? ( + {part.text}} + > + + + ) : ( +
+ {part.text} +
+ ) ) : part.type === "data-tool" ? ( + + + ); +}); diff --git a/frontend/src/ai/style.css b/frontend/src/ai/style.css index 3fe6fe3..62db5a3 100644 --- a/frontend/src/ai/style.css +++ b/frontend/src/ai/style.css @@ -82,6 +82,60 @@ line-height: 24px; font-weight: 400; } +.ai-markdown { + min-width: 0; + font-size: 14px; + line-height: 24px; +} +.ai-markdown .markstream-react { + color: var(--ink); + font-family: inherit; + font-size: inherit; + line-height: inherit; +} +.ai-markdown :is(h1, h2, h3, h4, h5, h6) { + font-size: 16px; + line-height: 24px; + font-weight: 600; + margin: 16px 0 8px; +} +.ai-markdown :is(p, ul, ol, blockquote, pre, table) { + margin-top: 8px; + margin-bottom: 8px; +} +.ai-chat .ai-markdown .markstream-react pre[data-markstream-pre] { + font-size: 12px; + max-width: 100%; + overflow-x: auto; + white-space: pre; + padding: 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--canvas); +} +.ai-markdown code { + font-size: 12px; +} +.ai-markdown table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; +} +.ai-markdown :is(th, td) { + padding: 6px 10px; + border: 1px solid var(--line); + white-space: nowrap; +} +.ai-markdown a { + color: var(--accent); + text-decoration: underline; +} +.ai-markdown blockquote { + border-left: 3px solid var(--line); + padding-left: 12px; + color: var(--muted); +} .ai-message.user .ai-text { background: var(--canvas); padding: var(--space-3); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0374689..db38407 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import "@douyinfe/semi-ui-19/react19-adapter"; import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +import "markstream-react/index.css"; import "./style.css"; import "./ai/style.css"; diff --git a/frontend/tests/ai.spec.ts b/frontend/tests/ai.spec.ts index 049408a..df48d26 100644 --- a/frontend/tests/ai.spec.ts +++ b/frontend/tests/ai.spec.ts @@ -269,3 +269,130 @@ test("Lark workspace keeps pagination, account details and chat usable at every fullPage: true, }); }); + +test("Markdown streams incomplete code, finishes safely and restores history", async ({ + page, +}) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await login(page); + await configure(page); + await page.getByRole("button", { name: "打开研究助手" }).click(); + const chat = page.getByRole("complementary", { name: "AI 研究助手" }); + await chat.getByRole("button", { name: "新会话", exact: true }).click(); + const prefix = "## 研究结论\n\n**正在生成**\n\n```python\nrank(close)"; + const suffix = + "\n```\n\n| 指标 | 值 |\n| --- | --- |\n| Sharpe | 1.2 |\n\n[文档](https://example.com)\n\n\n\n[危险](javascript:alert(1))"; + await page.evaluate(() => { + const original = window.fetch.bind(window); + window.fetch = async (url, init) => { + if (!String(url).match(/\/ai\/conversations\/[^/]+\/runs$/)) + return original(url, init); + return new Response( + new ReadableStream({ + start(controller) { + Object.assign(window, { + pushMarkdown: (events: unknown[], done = false) => { + for (const event of events) + controller.enqueue( + new TextEncoder().encode( + `data: ${JSON.stringify(event)}\n\n`, + ), + ); + if (done) { + controller.enqueue( + new TextEncoder().encode("data: [DONE]\n\n"), + ); + controller.close(); + } + }, + }); + }, + }), + { + headers: { + "content-type": "text/event-stream", + "x-vercel-ai-ui-message-stream": "v1", + }, + }, + ); + }; + }); + await chat + .getByRole("textbox", { name: "发送给研究助手" }) + .fill("**用户原文**"); + await chat.getByRole("button", { name: "发送", exact: true }).click(); + await page.waitForFunction( + () => typeof (window as any).pushMarkdown === "function", + ); + await page.evaluate( + (delta) => + (window as any).pushMarkdown([ + { type: "start", messageId: "markdown-answer" }, + { type: "text-start", id: "part" }, + { type: "text-delta", id: "part", delta }, + ]), + prefix, + ); + await expect(chat.getByRole("heading", { name: "研究结论" })).toBeVisible(); + await expect(chat.locator(".ai-markdown pre")).toContainText("rank(close)"); + await expect(chat.locator(".ai-messages")).toHaveAttribute( + "aria-busy", + "true", + ); + await expect(chat.locator(".ai-message.user .ai-text")).toHaveText( + "**用户原文**", + ); + await page.route("**/api/v1/ai/conversations/*", async (route) => { + if (route.request().method() !== "GET") return route.continue(); + await route.fulfill({ + json: { + id: route.request().url().split("/").at(-1), + title: "Markdown", + runs: [], + messages: [ + { + id: "markdown-answer", + role: "assistant", + parts: [{ type: "text", text: prefix + suffix }], + }, + ], + }, + }); + }); + await page.evaluate( + (delta) => + (window as any).pushMarkdown( + [ + { type: "text-delta", id: "part", delta }, + { type: "text-end", id: "part" }, + { type: "finish" }, + ], + true, + ), + suffix, + ); + await expect(chat.locator(".ai-messages")).toHaveAttribute( + "aria-busy", + "false", + ); + await expect(chat.getByRole("cell", { name: "1.2" })).toBeVisible(); + await expect( + chat.getByRole("link", { name: "Link: https://example.com", exact: true }), + ).toHaveAttribute("href", "https://example.com"); + await expect( + chat.locator(".ai-markdown script, .ai-markdown a[href^='javascript:']"), + ).toHaveCount(0); + await page.setViewportSize({ width: 390, height: 900 }); + expect( + await chat + .locator(".ai-messages") + .evaluate((el) => el.scrollWidth <= el.clientWidth), + ).toBe(true); + await page.screenshot({ path: "../output/playwright/ai-markdown.png" }); + await page.reload(); + await page.getByRole("button", { name: "打开研究助手" }).click(); + await expect(chat.getByRole("heading", { name: "研究结论" })).toBeVisible(); + await expect(chat.getByRole("cell", { name: "1.2" })).toBeVisible(); + expect(errors).toEqual([]); +});