From 69444006b0e21f6e17f4a1c9be40156bfdf18d0b Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:01:18 +0800 Subject: [PATCH] Traffic: render model-written Markdown in answers, thinking and system prompts Answers in a request's Content tab and in a session's conversation now show headings, bold, lists, tables and code blocks instead of the raw `**` and `#`. What the user typed is still shown as-is. marked only tokenizes; the tree is drawn as React elements, so HTML in the model's text stays text, links are not clickable (they would navigate the app window) and images are not fetched. Security hits are still highlighted, and while an answer streams only its last block re-renders, with the caret at the end of the last line. Co-Authored-By: Claude Opus 5.5 --- package.json | 1 + pnpm-lock.yaml | 10 ++ src/traffic/Conversation.tsx | 43 ++++--- src/traffic/Markdown.test.tsx | 50 ++++++++ src/traffic/Markdown.tsx | 234 ++++++++++++++++++++++++++++++++++ src/traffic/drawer/Parsed.tsx | 13 +- 6 files changed, 329 insertions(+), 22 deletions(-) create mode 100644 src/traffic/Markdown.test.tsx create mode 100644 src/traffic/Markdown.tsx diff --git a/package.json b/package.json index e3457f33..eb072dd0 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^1.45.0", + "marked": "^18.1.1", "radix-ui": "^1.6.7", "react": "^19.2.0", "react-day-picker": "^10.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 678d4d7d..6fab7dc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: lucide-react: specifier: ^1.45.0 version: 1.45.0(react@19.2.8) + marked: + specifier: ^18.1.1 + version: 18.1.1 radix-ui: specifier: ^1.6.7 version: 1.6.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2538,6 +2541,11 @@ packages: magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + marked@18.1.1: + resolution: {integrity: sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -5529,6 +5537,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + marked@18.1.1: {} + math-intrinsics@1.1.0: {} media-typer@1.1.1: {} diff --git a/src/traffic/Conversation.tsx b/src/traffic/Conversation.tsx index e7424f4c..2c7250fd 100644 --- a/src/traffic/Conversation.tsx +++ b/src/traffic/Conversation.tsx @@ -19,6 +19,7 @@ import { cn } from "@/lib/utils"; import { useHitTone, useMarker, type Mark } from "@/security/Highlight"; import { prettyJson } from "@/prettyJson"; import { BodyText } from "./drawer/Payload"; +import { Markdown } from "./Markdown"; import type { RequestRow, Transcript, @@ -687,7 +688,8 @@ export function BlockLine({ b }: { b: Block }) { const t = useText(conversationText); return ( - + {/* 用户自己打的字原样显示,模型写的按 Markdown 排 */} + ); } @@ -731,7 +733,7 @@ function EarlierBlocks({ messages }: { messages: readonly TranscriptMessage[] }) } /** 一块里的几段,上下排。相邻的图片和附件排成一行 */ -export function Parts({ parts }: { parts: readonly TranscriptPart[] }) { +export function Parts({ parts, plain = false }: { parts: readonly TranscriptPart[]; plain?: boolean }) { const runs: (TranscriptPart | TranscriptPart[])[] = []; for (const p of parts) { const chip = p.kind === "image" || p.kind === "other"; @@ -749,17 +751,17 @@ export function Parts({ parts }: { parts: readonly TranscriptPart[] }) { ))} ) : ( - + ), )} ); } -function Part({ p }: { p: TranscriptPart }) { +function Part({ p, plain }: { p: TranscriptPart; plain: boolean }) { switch (p.kind) { case "text": - return ; + return ; case "thinking": return ; case "tool_call": @@ -773,27 +775,34 @@ function Part({ p }: { p: TranscriptPart }) { } /** - * 说的话:原样的空白和换行,不当 Markdown 解析(模型写的 `**` 和 `#` 就是那几个字符)。 - * 长的先显示开头,「展开全部」看整段。 + * 说的话:模型写的(回答、思考、系统提示)按 Markdown 排(`Markdown`);`plain` 的(用户打的字) + * 原样的空白和换行。长的先显示开头,「展开全部」看整段。 */ -export function Prose({ text, muted = false }: { text: string; muted?: boolean }) { +export function Prose({ text, muted = false, plain = false }: { text: string; muted?: boolean; plain?: boolean }) { const t = useText(conversationText); const [all, setAll] = useState(false); const head = useMemo(() => clip(text, PROSE), [text]); const cut = head !== null && !all; const mark = useMarker(); + const ellipsis = …; return (
{/* 上边 2px:一行字的中线和左边标签的中线对齐(标签那一格 24px 高,字的行高 19.5px) */} -

- {mark(cut ? head : text)} - {cut && …} -

+ {plain ? ( +

+ {mark(cut ? head : text)} + {cut && ellipsis} +

+ ) : ( +
+ +
+ )} {head !== null && (