diff --git a/docs/images/appearance/conversation-dark.png b/docs/images/appearance/conversation-dark.png index 9687f7ee..ef78a863 100644 Binary files a/docs/images/appearance/conversation-dark.png and b/docs/images/appearance/conversation-dark.png differ diff --git a/docs/images/appearance/conversation-light.png b/docs/images/appearance/conversation-light.png index 874f67c2..bd96ebbf 100644 Binary files a/docs/images/appearance/conversation-light.png and b/docs/images/appearance/conversation-light.png differ diff --git a/docs/images/appearance/conversation-mist.png b/docs/images/appearance/conversation-mist.png index aa3457dc..ee34ef47 100644 Binary files a/docs/images/appearance/conversation-mist.png and b/docs/images/appearance/conversation-mist.png differ diff --git a/docs/images/appearance/light-dark.png b/docs/images/appearance/light-dark.png index 1f94d995..a4b8fe78 100644 Binary files a/docs/images/appearance/light-dark.png and b/docs/images/appearance/light-dark.png differ diff --git a/docs/usage.md b/docs/usage.md index 780da408..c182691c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -111,6 +111,11 @@ Ctrl+Shift+V(macOS 为 Cmd+Shift+V)按纯文本粘贴,保留 Markdown 标 围栏块中;块顶部显示“文本”和复制按钮,复制内容不包含围栏。代码围栏显示语言名称。 普通段落保留整条回复的复制入口。 +聊天正文使用 14 px 字号、1.625 倍行高和正常字距,代码块保持 13 px。 +已完成的段落采用智能换行,标题均衡换行,长标识符在空间不足时折行。 +实时回复的新片段以 150 ms 的轻微模糊淡入显示,不额外等待或延迟输出;历史消息和 +代码块直接显示。开启系统的“减少动态效果”后,文字直接出现。 + Agent 建议固定任务要求时,问题会显示在输入框上方的独立卡片中。可选择“本次任务始终保留” 或“仅当前讨论”,也可直接撰写回复,再点击“发送”。自定义回复只有勾选“将这段回复固定到本次任务” 才会成为固定要求;“跳过”不固定内容,也不发送答复。回答失败时可重试原答复。 diff --git a/web/src/v2/components/narrative-streaming.test.tsx b/web/src/v2/components/narrative-streaming.test.tsx new file mode 100644 index 00000000..9d5e7e96 --- /dev/null +++ b/web/src/v2/components/narrative-streaming.test.tsx @@ -0,0 +1,83 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { APIClient } from "../../api/client"; +import type { NarrativeEntry } from "../projection/narrative"; +import { V2Narrative } from "./narrative"; + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +const client = {} as APIClient; +const assistant = (text: string, provisional = true): NarrativeEntry => ({ + id: "reply", kind: "assistant", text, createdAt: "", provisional, runId: "run-1", +}); + +it("reveals only new appends and does not replay a loaded, corrected or completed reply", () => { + const content = (text: string, provisional = true) => + ; + const view = render(content("已读取说明。")); + const reveal = () => view.container.querySelector(".v2-stream-reveal"); + expect(reveal()).toBeNull(); + view.rerender(content("已读取说明。开始修改。")); + const first = reveal(); + expect(first).toHaveTextContent("开始修改。"); + view.rerender(content("已读取说明。开始修改。完成检查。")); + expect(reveal()).toHaveTextContent("完成检查。"); + expect(reveal()).not.toBe(first); + expect(view.container.querySelectorAll(".v2-stream-reveal")).toHaveLength(1); + view.rerender(content("已更正。")); + expect(reveal()).toBeNull(); + view.rerender(content("已更正。最终回复。", false)); + expect(reveal()).toBeNull(); + expect(screen.getByRole("button", { name: "复制回复" })).toBeInTheDocument(); +}); + +it("keeps Markdown emphasis and file links intact as unfinished markup closes", () => { + const openFile = vi.fn(); + const content = (text: string) => ; + const view = render(content("已有 **加粗")); + view.rerender(content("已有 **加粗**,查看 [README.md](README.md:3)。")); + expect(view.container.querySelector("strong")).toHaveTextContent("加粗"); + expect(view.container.querySelector("strong .v2-stream-reveal")).toBeNull(); + fireEvent.click(screen.getByRole("link", { name: "README.md" })); + expect(openFile).toHaveBeenCalledWith(expect.objectContaining({ path: "README.md", line: 3, runID: "run-1" })); + const link = screen.getByRole("link", { name: "README.md" }); + view.rerender(content("已有 **加粗**,查看 [README.md](README.md:3)。继续。")); + expect(screen.getByRole("link", { name: "README.md" })).toBe(link); + expect(link.querySelector(".v2-stream-reveal")).toBeNull(); +}); + +it("leaves code stable during streaming and copies the original code and reply", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + const content = (text: string, provisional = true) => + ; + const start = "说明。\n\n```ts\nconst one = 1;\n"; + const full = start + "const two = 2;\n```\n\n完成 👩‍💻 é。"; + const view = render(content(start)); + const code = view.container.querySelector("pre"); + view.rerender(content(full)); + expect(view.container.querySelector("pre")).toBe(code); + expect(code?.querySelector(".v2-stream-reveal")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "复制代码" })); + await waitFor(() => expect(writeText).toHaveBeenLastCalledWith("const one = 1;\nconst two = 2;\n")); + view.rerender(content(full, false)); + fireEvent.click(screen.getByRole("button", { name: "复制回复" })); + await waitFor(() => expect(writeText).toHaveBeenLastCalledWith(full)); +}); + +it.each([ + ["Emoji 👩", "Emoji 👩‍💻 后文", " 后文"], + ["Accent e", "Accent é 后文", " 后文"], + ["旧 & 内容", "旧 & 内容 新增", undefined], + ["旧 \\* 内容", "旧 \\* 内容 新增", undefined], +] as const)("preserves graphemes and escaped source when appending to %s", (before, after, revealed) => { + const content = (text: string, provisional = true) => + ; + const view = render(content(before)); + view.rerender(content(after)); + expect(view.container.querySelector(".v2-stream-reveal")?.textContent).toBe(revealed); + const streamedText = view.container.querySelector(".v2-assistant-turn > p")?.textContent; + view.rerender(content(after, false)); + expect(view.container.querySelector(".v2-assistant-turn > p")?.textContent).toBe(streamedText); +}); diff --git a/web/src/v2/components/narrative.tsx b/web/src/v2/components/narrative.tsx index 4ef060fa..8462e3e7 100644 --- a/web/src/v2/components/narrative.tsx +++ b/web/src/v2/components/narrative.tsx @@ -1,6 +1,6 @@ import { Children, isValidElement, memo, useMemo, useState } from "react"; import { replaceEqualDeep } from "@tanstack/react-query"; -import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; +import ReactMarkdown, { defaultUrlTransform, type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { Check, Copy } from "lucide-react"; import { remarkCjkAutolinks } from "../../components/remark-cjk-autolinks"; @@ -9,6 +9,7 @@ import type { NarrativeEntry } from "../projection/narrative"; import { V2ImagePreview } from "./image-input"; import { V2FileAttachments } from "./file-input"; import { V2ActivityGroup } from "./activity-detail"; +import { rehypeStreamReveal } from "./stream-reveal"; export interface FileLinkTarget { path: string; @@ -141,38 +142,54 @@ interface NarrativeRowProps { }) => void; } +const StreamRevealSpan: Components["span"] = ({ node, children, ...props }) => + {children}; + function MessageMarkdown({ entry, onOpenFile }: { entry: Extract; onOpenFile: NarrativeRowProps["onOpenFile"]; }) { + const live = entry.kind === "assistant" && entry.provisional; + const [reveal, setReveal] = useState({ text: entry.text, live, start: undefined as number | undefined }); + if (reveal.text !== entry.text || reveal.live !== live) { + // Only an append to an already visible live message animates. Opening a + // thread, correcting text and replacing it with durable output stay immediate. + setReveal({ text: entry.text, live, start: live && reveal.live && + entry.text.length > reveal.text.length && entry.text.startsWith(reveal.text) + ? reveal.text.length : undefined }); + } + const runID = "runId" in entry ? entry.runId : undefined; + const components = useMemo(() => ({ + a: ({ node: _node, href, children, ...props }) => { + const fileLink = parseProjectFileLink(href ?? ""); + if (fileLink) { + return { + event.preventDefault(); + onOpenFile?.({ + path: fileLink.path, + line: fileLink.line, + runID, + triggerElement: event.currentTarget, + }); + }} + {...props} + >{children}; + } + return {children}; + }, + pre: ({ node: _node, children, ...props }) => {children}, + span: StreamRevealSpan, + }), [onOpenFile, runID]); return (parseProjectFileLink(url) ? url : defaultUrlTransform(url))} - components={{ - a: ({ href, children, ...props }) => { - const fileLink = parseProjectFileLink(href ?? ""); - if (fileLink) { - return { - event.preventDefault(); - onOpenFile?.({ - path: fileLink.path, - line: fileLink.line, - runID: "runId" in entry ? entry.runId : undefined, - triggerElement: event.currentTarget, - }); - }} - {...props} - >{children}; - } - return {children}; - }, - pre: ({ children, ...props }) => {children}, - }} - >{entry.text}; + remarkPlugins={[remarkGfm, remarkCjkAutolinks]} + rehypePlugins={reveal.start === undefined ? [] : [[rehypeStreamReveal, { start: reveal.start }]]} + urlTransform={(url) => (parseProjectFileLink(url) ? url : defaultUrlTransform(url))} + components={components} + >{entry.text}; } const NarrativeRow = memo(function NarrativeRow({ client, entry, threadID, onOpenFile }: NarrativeRowProps) { diff --git a/web/src/v2/components/stream-reveal.ts b/web/src/v2/components/stream-reveal.ts new file mode 100644 index 00000000..9702e1cd --- /dev/null +++ b/web/src/v2/components/stream-reveal.ts @@ -0,0 +1,41 @@ +import type { Element, Root, RootContent, Text } from "hast"; + +const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +// Decorate the parsed Markdown, keeping its links, emphasis and copyable text. +// At most the latest append is wrapped; history never accumulates character spans. +export function rehypeStreamReveal({ start }: { start: number }) { + return (tree: Root, file: { value: unknown }) => { + const source = String(file.value); + const visit = (parent: Root | Element) => { + parent.children = parent.children.flatMap((node): RootContent[] => { + if (node.type === "element") { + if (node.tagName !== "pre" && node.tagName !== "code") visit(node); + return [node]; + } + if (node.type !== "text") return [node]; + const from = node.position?.start.offset; + const to = node.position?.end.offset; + if (from === undefined || to === undefined || to <= start) return [node]; + let cut = 0; + if (from < start) { + // Entities and escapes change source offsets. Do not replay older text + // when its rendered value cannot be split at the source boundary. + if (node.value !== source.slice(from, to)) return [node]; + cut = node.value.length; + for (const segment of graphemes.segment(node.value)) { + if (segment.index >= start - from) { cut = segment.index; break; } + } + } + if (cut === node.value.length) return [node]; + const prefix: Text[] = cut ? [{ type: "text", value: node.value.slice(0, cut) }] : []; + return [...prefix, { + type: "element", tagName: "span", + properties: { className: ["v2-stream-reveal"], dataStreamStart: start }, + children: [{ type: "text", value: node.value.slice(cut) }], + }]; + }); + }; + visit(tree); + }; +} diff --git a/web/src/v2/styles.css b/web/src/v2/styles.css index 067ed9cd..1b385c50 100644 --- a/web/src/v2/styles.css +++ b/web/src/v2/styles.css @@ -765,7 +765,16 @@ .v2-conversation-scroll { min-height: 0; overflow: auto; overflow-anchor: none; overscroll-behavior: contain; scrollbar-color: rgb(79 79 79 / 18%) transparent; scrollbar-width: thin; } .v2-conversation-content { margin: 0 auto; max-width: var(--v2-content-width); padding: 28px 0 38px; width: calc(100% - 40px); } -.v2-narrative { display: grid; grid-template-columns: minmax(0, 1fr); gap: 22px; list-style: none; margin: 0; padding: 0; } +.v2-narrative { + --prayu-prose-size: 14px; + --prayu-prose-leading: 1.625; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 22px; + list-style: none; + margin: 0; + padding: 0; +} .v2-narrative > li { min-width: 0; } .v2-user-turn { display: flex; justify-content: flex-end; } .v2-message-status { color: var(--v2-muted); display: block; font-size: 11px; margin-top: 6px; } @@ -783,25 +792,42 @@ white-space: pre-wrap; } .v2-user-markdown { white-space: normal; } -:is(.v2-assistant-turn, .v2-user-markdown) { font-size: var(--prayu-prose-size); font-weight: var(--prayu-weight-body); line-height: var(--prayu-prose-leading); max-width: 100%; overflow-wrap: anywhere; } +:is(.v2-assistant-turn, .v2-user-markdown) { + font-size: var(--prayu-prose-size); + font-weight: var(--prayu-weight-body); + letter-spacing: 0; + line-height: var(--prayu-prose-leading); + max-width: 100%; + overflow-wrap: anywhere; + word-break: normal; +} :is(.v2-assistant-turn, .v2-user-markdown) > :first-child { margin-top: 0; } :is(.v2-assistant-turn, .v2-user-markdown) > :last-child { margin-bottom: 0; } :is(.v2-assistant-turn, .v2-user-markdown) p { margin: 0 0 .85em; } +:is(.v2-assistant-turn:not(.is-provisional), .v2-user-markdown) :is(p, li) { text-wrap: pretty; } :is(.v2-assistant-turn, .v2-user-markdown) :is(h1, h2, h3, h4, h5, h6) { font-weight: var(--prayu-weight-heading); line-height: 1.4; letter-spacing: 0; margin: 1.5em 0 .6em; + text-wrap: balance; } :is(.v2-assistant-turn, .v2-user-markdown) h1 { font-size: 1.35em; } :is(.v2-assistant-turn, .v2-user-markdown) h2 { font-size: 1.2em; } :is(.v2-assistant-turn, .v2-user-markdown) :is(h3, h4, h5, h6) { font-size: 1em; } :is(.v2-assistant-turn, .v2-user-markdown) :is(strong, b) { font-weight: var(--prayu-weight-heading); } +:is(.v2-assistant-turn, .v2-user-markdown) a:not(.v2-file-link) { + color: inherit; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 55%, transparent); + text-underline-offset: .18em; +} +:is(.v2-assistant-turn, .v2-user-markdown) a:not(.v2-file-link):hover { text-decoration-color: currentColor; } :is(.v2-assistant-turn, .v2-user-markdown) :is(ul, ol) { margin: .7em 0 1em; padding-inline-start: 1.6em; } :is(.v2-assistant-turn, .v2-user-markdown) li + li { margin-top: .35em; } :is(.v2-assistant-turn, .v2-user-markdown) li > p { margin-block: .35em; } :is(.v2-assistant-turn, .v2-user-markdown) blockquote { border-inline-start: 2px solid var(--v2-border-strong); color: var(--v2-muted); margin: 1em 0; padding-inline-start: 1em; } -:is(.v2-assistant-turn, .v2-user-markdown) table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; font-size: 13px; line-height: 1.6; } +:is(.v2-assistant-turn, .v2-user-markdown) table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; line-height: 1.6; } :is(.v2-assistant-turn, .v2-user-markdown) :is(th, td) { border-bottom: 1px solid var(--v2-border); padding: 9px 12px; text-align: start; vertical-align: top; } :is(.v2-assistant-turn, .v2-user-markdown) th { font-size: inherit; font-weight: var(--prayu-weight-heading); letter-spacing: 0; text-transform: none; } :is(.v2-assistant-turn, .v2-user-markdown) pre { @@ -816,6 +842,11 @@ :is(.v2-assistant-turn, .v2-user-markdown) .v2-markdown-code-block { margin-block: 1em; } :is(.v2-assistant-turn, .v2-user-markdown) .v2-markdown-code-block pre { background: transparent; border-radius: 0; font-size: var(--prayu-code-size); line-height: var(--prayu-code-leading); margin: 0; } :is(.v2-assistant-turn, .v2-user-markdown) .v2-markdown-code-block pre code { font-size: inherit; } +.v2-stream-reveal { animation: v2-stream-reveal 150ms ease-out both; } +@keyframes v2-stream-reveal { + from { opacity: .25; filter: blur(.6px); } + to { opacity: 1; filter: blur(0); } +} .v2-activity-turn { margin: -3px 0; } .v2-activity { color: var(--v2-muted); } .v2-activity > summary {