Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/en/developer-guide/wework-performance-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ WEGENT_CODEX_STREAM_DEBUG=1 # enable raw Codex delta / classification d
WEGENT_CODEX_STREAM_MAPPING_DEBUG=1 # enable runtime work cache/emit mapping details
```

## Streaming Message Rendering

Wework separates high-frequency executor text deltas from the visible Markdown cadence. Message state still receives and retains the complete content in real time, while `AssistantMarkdown` uses a lightweight buffer to advance visible text on browser frames. It catches up adaptively when the backlog grows, then retains a small character reserve and drains it slowly near the tail to smooth executor bursts and short delivery gaps. The renderer immediately aligns with complete content when streaming ends, content is replaced, or an update is not append-only, preserving final-message correctness.

Streaming messages skip full Pretext height measurement and use a stable offscreen intrinsic height. Completed messages are measured precisely and cached. Height lookup first uses the message object and width, avoiding repeated full-text hashes for unchanged historical messages during every stream update. Stable props and memo boundaries also keep the composer, workspace actions, right workspace, and bottom terminal from rerendering for every text delta.

Distinguish these cases when investigating streaming stalls:

- The frame rate is stable but output alternates between fast and slow: inspect stream `message` event intervals. Executor batching or network/IPC delivery gaps are usually responsible.
- Long frames, dense style recalculation, or Markdown parsing appear: check whether code bypasses the text buffer, destabilizes Streamdown component references, or reintroduces per-character DOM animation.
- GC time is unexpectedly high: verify whether Web Inspector has **Heap Allocations** enabled. That instrument can significantly amplify GC during longer recordings and should be disabled when diagnosing interaction smoothness alone.

Streaming-buffer unit tests live in `wework/src/components/chat/useBufferedStreamingText.test.ts`. Changes to the reserve or advance rate must continue to cover Unicode boundaries, non-append updates, and immediate alignment when streaming ends.

## Collected Data

When enabled, the diagnostics module records:
Expand Down
14 changes: 14 additions & 0 deletions docs/zh/developer-guide/wework-performance-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ WEGENT_CODEX_STREAM_DEBUG=1 # 开启 Codex 原始 delta / 分类详情
WEGENT_CODEX_STREAM_MAPPING_DEBUG=1 # 开启 runtime work cache/emit mapping 详情
```

## 流式消息渲染

Wework 将 executor 高频到达的文本增量与 Markdown 展示节奏分离。消息状态仍实时接收并保持完整内容,`AssistantMarkdown` 使用轻量缓冲器在浏览器帧上逐步推进可见文本:积压较多时自适应加速,接近队尾时保留少量字符并缓慢释放,以平滑 executor 批量发送和短暂空档造成的视觉顿挫。流结束、内容被替换或发生非追加更新时会立即对齐完整文本,不会影响最终消息正确性。

流式消息不执行 Pretext 全文高度测量,而是使用稳定的离屏占位高度;消息完成后再精确测量并缓存。已完成消息优先按消息对象和宽度命中高度缓存,避免每次流式更新都重新计算历史消息全文 hash。Composer、Workspace 操作栏、右侧工作区和底部终端也使用稳定属性与 memo 边界,避免随每个文本增量重复渲染。

排查流式卡顿时应区分以下情况:

- 帧率稳定但输出一阵快、一阵慢:检查 stream `message` 事件间隔,通常是 executor 增量批量到达或存在网络/IPC 空档。
- 存在长帧、密集样式重算或 Markdown 解析:检查是否绕过了文本缓冲、破坏了 Streamdown 组件引用稳定性,或重新引入了逐字符 DOM 动画。
- GC 时间异常高:确认 Web Inspector 是否开启 **Heap Allocations**。该采集项会显著放大长时间录制中的 GC;只排查交互流畅度时应关闭它。

流式文本缓冲的单元测试位于 `wework/src/components/chat/useBufferedStreamingText.test.ts`。修改缓冲水位或推进速率时,应同时验证 Unicode 字符边界、非追加更新和流结束立即对齐行为。

## 采集内容

开启后,诊断模块会采集:
Expand Down
188 changes: 107 additions & 81 deletions wework/src/components/chat/AssistantMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { memo, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { HTMLAttributes, ReactNode } from 'react'
import type { Element as HastElement } from 'hast'
import { FileText, Link2 } from 'lucide-react'
import { Streamdown } from 'streamdown'
import 'streamdown/styles.css'
Expand All @@ -11,6 +12,7 @@ import {
type MarkdownLinkTarget,
} from './assistantMarkdownLinks'
import { MarkdownCodeBlock } from './MarkdownCodeBlock'
import { useBufferedStreamingText } from './useBufferedStreamingText'
import type { WorkspaceFileOpenOptions } from '@/types/workspace-files'

const ASSISTANT_MARKDOWN_LINK_CLASS = [
Expand All @@ -37,20 +39,89 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
isStreaming = false,
onOpenFile,
}: AssistantMarkdownProps) {
const displayContent = prepareAssistantMarkdownContent(content)
const bufferedContent = useBufferedStreamingText(content, isStreaming)
const displayContent = prepareAssistantMarkdownContent(bufferedContent)
const openFileRef = useRef(onOpenFile)

useEffect(() => {
openFileRef.current = onOpenFile
}, [onOpenFile])

const openFile = (path: string, options?: WorkspaceFileOpenOptions) => {
const openFile = useCallback((path: string, options?: WorkspaceFileOpenOptions) => {
if (options) {
openFileRef.current?.(path, options)
return
}
openFileRef.current?.(path)
}
}, [])
const components = useMemo(
() => ({
h1: ({ children }: { children?: ReactNode }) => (
<h1 data-scroll-anchor className="mb-4 mt-6 text-lg font-semibold text-text-primary">
{children}
</h1>
),
h2: ({ children }: { children?: ReactNode }) => (
<h2 data-scroll-anchor className="mb-3 mt-5 text-base font-semibold text-text-primary">
{children}
</h2>
),
h3: ({ children }: { children?: ReactNode }) => (
<h3 data-scroll-anchor className="mb-2 mt-4 text-sm font-semibold text-text-primary">
{children}
</h3>
),
p: ({ children }: { children?: ReactNode }) => (
<p data-scroll-anchor className="mb-3 min-w-0 break-words leading-6">
{children}
</p>
),
ul: ({ children }: { children?: ReactNode }) => (
<ul className="mb-3 list-disc space-y-1.5 pl-5">{children}</ul>
),
ol: ({ children }: { children?: ReactNode }) => (
<ol className="mb-3 list-decimal space-y-1.5 pl-8">{children}</ol>
),
li: ({ children }: { children?: ReactNode }) => (
<li data-scroll-anchor className="min-w-0 break-words pl-1 leading-6">
{children}
</li>
),
strong: ({ children }: { children?: ReactNode }) => (
<strong className="font-semibold">{children}</strong>
),
code: MarkdownCode,
inlineCode: MarkdownInlineCode,
blockquote: ({ children }: { children?: ReactNode }) => (
<blockquote
data-scroll-anchor
className="mb-3 border-l-3 border-border pl-4 text-text-secondary"
>
{children}
</blockquote>
),
table: ({ children }: { children?: ReactNode }) => (
<div data-scroll-anchor className="mb-3 max-w-full overflow-x-auto">
<table className="w-full min-w-max border-collapse text-[13px]">{children}</table>
</div>
),
th: ({ children }: { children?: ReactNode }) => (
<th className="border-b border-border px-3 py-2 text-left font-semibold">{children}</th>
),
td: ({ children }: { children?: ReactNode }) => (
<td className="border-b border-border px-3 py-2">{children}</td>
),
a: ({ href, children }: { href?: string; children?: ReactNode }) => (
<AssistantMarkdownLink href={href} onOpenFile={openFile}>
{children}
</AssistantMarkdownLink>
),
img: ({ src, alt }: { src?: string; alt?: string }) => (
<AssistantMarkdownImage src={src} alt={alt} />
),
}),
[openFile]
)

return (
<div className="assistant-markdown min-w-0 max-w-full break-words">
Expand All @@ -61,89 +132,44 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
linkSafety={{ enabled: false }}
lineNumbers={false}
urlTransform={url => url}
components={{
h1: ({ children }) => (
<h1 data-scroll-anchor className="mb-4 mt-6 text-lg font-semibold text-text-primary">
{children}
</h1>
),
h2: ({ children }) => (
<h2 data-scroll-anchor className="mb-3 mt-5 text-base font-semibold text-text-primary">
{children}
</h2>
),
h3: ({ children }) => (
<h3 data-scroll-anchor className="mb-2 mt-4 text-sm font-semibold text-text-primary">
{children}
</h3>
),
p: ({ children }) => (
<p data-scroll-anchor className="mb-3 min-w-0 break-words leading-6">
{children}
</p>
),
ul: ({ children }) => <ul className="mb-3 list-disc space-y-1.5 pl-5">{children}</ul>,
ol: ({ children }) => <ol className="mb-3 list-decimal space-y-1.5 pl-8">{children}</ol>,
li: ({ children }) => (
<li data-scroll-anchor className="min-w-0 break-words pl-1 leading-6">
{children}
</li>
),
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
code: ({ className, children, node, ...props }) => {
const match = /language-(\w*)/.exec(className || '')
const text = reactNodeToText(children)
const isBlock =
('data-block' in props && Boolean(props['data-block'])) ||
node?.properties?.dataBlock === 'true' ||
Boolean(match) ||
text.includes('\n')
if (isBlock) {
const lang = match ? match[1] || '' : ''
return <MarkdownCodeBlock lang={lang}>{text || children}</MarkdownCodeBlock>
}
return (
<code className="break-words rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-text-primary">
{children}
</code>
)
},
inlineCode: ({ children }) => (
<code className="break-words rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-text-primary">
{children}
</code>
),
blockquote: ({ children }) => (
<blockquote
data-scroll-anchor
className="mb-3 border-l-3 border-border pl-4 text-text-secondary"
>
{children}
</blockquote>
),
table: ({ children }) => (
<div data-scroll-anchor className="mb-3 max-w-full overflow-x-auto">
<table className="w-full min-w-max border-collapse text-[13px]">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border-b border-border px-3 py-2 text-left font-semibold">{children}</th>
),
td: ({ children }) => <td className="border-b border-border px-3 py-2">{children}</td>,
a: ({ href, children }) => (
<AssistantMarkdownLink href={href} onOpenFile={openFile}>
{children}
</AssistantMarkdownLink>
),
img: ({ src, alt }) => <AssistantMarkdownImage src={src} alt={alt} />,
}}
components={components}
>
{displayContent}
</Streamdown>
</div>
)
}, areAssistantMarkdownPropsEqual)

function MarkdownCode({
className,
children,
node,
...props
}: {
node?: HastElement
} & HTMLAttributes<HTMLElement>) {
const match = /language-(\w*)/.exec(className || '')
const text = reactNodeToText(children)
const isBlock =
('data-block' in props && Boolean(props['data-block'])) ||
node?.properties?.dataBlock === 'true' ||
Boolean(match) ||
text.includes('\n')
if (isBlock) {
const lang = match ? match[1] || '' : ''
return <MarkdownCodeBlock lang={lang}>{text || children}</MarkdownCodeBlock>
}
return <MarkdownInlineCode>{children}</MarkdownInlineCode>
}

function MarkdownInlineCode({ children }: { children?: ReactNode }) {
return (
<code className="break-words rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-text-primary">
{children}
</code>
)
}

function areAssistantMarkdownPropsEqual(
previous: AssistantMarkdownProps,
next: AssistantMarkdownProps
Expand Down
19 changes: 13 additions & 6 deletions wework/src/components/chat/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -566,8 +566,11 @@ describe('MessageList', () => {
)

expect(screen.queryByTestId('assistant-plan-card')).not.toBeInTheDocument()
expect(screen.getByText('Inspect the desktop chat width.')).toBeInTheDocument()
expect(screen.getByText('Match the reference task.')).toBeInTheDocument()
const planItems = screen.getAllByRole('listitem')
expect(planItems.map(item => item.textContent)).toEqual([
'Inspect the desktop chat width.',
'Match the reference task.',
])
expect(screen.queryByText(/proposed_plan/)).not.toBeInTheDocument()
})

Expand Down Expand Up @@ -815,7 +818,9 @@ describe('MessageList', () => {
)

expect(screen.queryByTestId('assistant-plan-card')).not.toBeInTheDocument()
expect(screen.getByText('Wegent 代码质量与前端一致性巡检计划')).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 1, name: 'Wegent 代码质量与前端一致性巡检计划' })
).toBeInTheDocument()
})

test('shows thinking after partial streaming assistant content', () => {
Expand All @@ -833,9 +838,10 @@ describe('MessageList', () => {
/>
)

const content = screen.getByText('我已经完成前面的检查,继续等最后结果。')
const content = screen.getByTestId('message-assistant').querySelector('p')
const thinking = screen.getByTestId('thinking-indicator')

expect(content).toHaveTextContent('我已经完成前面的检查,继续等最后结果。')
expect(thinking).toHaveTextContent('正在思考')
expect(content.compareDocumentPosition(thinking) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
Expand Down Expand Up @@ -1402,7 +1408,8 @@ describe('MessageList', () => {
/>
)

const finalAnswer = screen.getByText('这是正在流式输出的最终答案。')
const finalAnswer = screen.getByTestId('message-assistant').querySelector('p')
expect(finalAnswer).toHaveTextContent('这是正在流式输出的最终答案。')
const processStatus = screen.getByRole('button', { name: /已处理/ })
const collapseContent = screen.getByTestId('processing-collapse-content')

Expand Down Expand Up @@ -1958,7 +1965,7 @@ describe('MessageList', () => {
/>
)

expect(screen.getByText(/See/)).toBeInTheDocument()
expect(screen.getByTestId('message-assistant').querySelector('p')).toHaveTextContent(/See/)
expect(screen.queryByTestId('codex-reference-list')).not.toBeInTheDocument()
expect(screen.queryByTestId('codex-memory-citations')).not.toBeInTheDocument()
expect(screen.queryByTestId('file-changes-card')).not.toBeInTheDocument()
Expand Down
14 changes: 14 additions & 0 deletions wework/src/components/chat/messagePretextLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,18 @@ describe('messagePretextLayout', () => {

expect(getMessagePretextIntrinsicHeight(message, 140)).toBe(272)
})

test('uses a stable intrinsic height without measuring streaming messages', () => {
const message: WorkbenchMessage = {
id: 'assistant-streaming',
role: 'assistant',
content: 'x'.repeat(20_000),
status: 'streaming',
createdAt: '2026-07-10T10:00:00Z',
}

expect(getMessagePretextIntrinsicHeight(message, 500)).toBe(220)
expect(mockedPrepare).not.toHaveBeenCalled()
expect(mockedLayout).not.toHaveBeenCalled()
})
})
Loading
Loading