Skip to content

Commit de9e4b7

Browse files
authored
Merge pull request #590 from Cai-Tang-www/feat/issue-586-web-ui-polish
fix(web): 收敛 Streamdown 渲染与聊天区可读性,修复 #586
2 parents bc8f430 + 73db830 commit de9e4b7

17 files changed

Lines changed: 2617 additions & 199 deletions

web/package-lock.json

Lines changed: 1910 additions & 38 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,15 @@
2020
"dependencies": {
2121
"@fontsource/jetbrains-mono": "^5.2.8",
2222
"@monaco-editor/react": "^4.7.0",
23+
"@streamdown/cjk": "^1.0.3",
24+
"@streamdown/code": "^1.1.1",
2325
"electron-updater": "^6.8.3",
2426
"lucide-react": "^0.468.0",
2527
"monaco-editor": "^0.52.2",
2628
"react": "^18.3.1",
2729
"react-dom": "^18.3.1",
28-
"react-markdown": "^10.1.0",
2930
"react-router-dom": "^6.28.0",
30-
"remark-gfm": "^4.0.1",
31+
"streamdown": "^2.5.0",
3132
"zustand": "^5.0.2"
3233
},
3334
"devDependencies": {
@@ -39,6 +40,7 @@
3940
"@types/react": "^18.3.18",
4041
"@types/react-dom": "^18.3.5",
4142
"@vitejs/plugin-react": "^4.3.4",
43+
"@vitest/coverage-v8": "^4.1.5",
4244
"electron": "^33.2.0",
4345
"electron-builder": "^25.1.8",
4446
"happy-dom": "^20.9.0",

web/src/components/chat/ChatInput.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ function BudgetTokenStrip() {
316316
const tokenUsage = useChatStore((s) => s.tokenUsage)
317317
const [open, setOpen] = useState(false)
318318
const ref = useRef<HTMLDivElement>(null)
319+
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({})
319320

320321
const totalTokens = tokenUsage ? tokenUsage.input_tokens + tokenUsage.output_tokens : 0
321322
const ratio = budgetUsageRatio ?? 0
@@ -332,11 +333,38 @@ function BudgetTokenStrip() {
332333
// Click outside to close
333334
useEffect(() => {
334335
if (!open) return
336+
337+
/** 根据锚点动态计算弹层位置,避免被容器裁剪或超出视口。 */
338+
function updatePopoverPosition() {
339+
const anchor = ref.current
340+
if (!anchor) return
341+
const rect = anchor.getBoundingClientRect()
342+
const width = 260
343+
const leftMin = 12
344+
const leftMax = Math.max(leftMin, window.innerWidth - width - 12)
345+
const preferredLeft = rect.right - width + 6
346+
const left = Math.min(leftMax, Math.max(leftMin, preferredLeft))
347+
const bottom = Math.max(36, window.innerHeight - rect.top + 8)
348+
setPopoverStyle({
349+
position: 'fixed',
350+
left,
351+
bottom,
352+
width,
353+
})
354+
}
355+
356+
updatePopoverPosition()
335357
function click(e: MouseEvent) {
336358
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
337359
}
360+
window.addEventListener('resize', updatePopoverPosition)
361+
window.addEventListener('scroll', updatePopoverPosition, true)
338362
document.addEventListener('mousedown', click)
339-
return () => document.removeEventListener('mousedown', click)
363+
return () => {
364+
window.removeEventListener('resize', updatePopoverPosition)
365+
window.removeEventListener('scroll', updatePopoverPosition, true)
366+
document.removeEventListener('mousedown', click)
367+
}
340368
}, [open])
341369

342370
if (!budgetChecked && !totalTokens) return null
@@ -364,7 +392,7 @@ function BudgetTokenStrip() {
364392
</div>
365393

366394
{open && (
367-
<div className="budget-popover" style={{ bottom: 28, right: -8 }}>
395+
<div className="budget-popover" style={popoverStyle}>
368396
<div className="budget-popover-title">用量明细</div>
369397
{budgetEstimateFailed ? (
370398
<div style={{ color: 'var(--error)', fontSize: 11 }}>{budgetEstimateFailed.message}</div>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { render, screen } from '@testing-library/react'
2+
import { describe, expect, it } from 'vitest'
3+
import MarkdownContent from './MarkdownContent'
4+
5+
describe('MarkdownContent', () => {
6+
it('renders GFM tables correctly', async () => {
7+
render(<MarkdownContent content={'| A | B |\n| - | - |\n| 1 | 2 |'} />)
8+
expect(await screen.findByText('A')).toBeTruthy()
9+
expect(await screen.findByText('B')).toBeTruthy()
10+
expect(await screen.findByText('1')).toBeTruthy()
11+
expect(await screen.findByText('2')).toBeTruthy()
12+
})
13+
14+
it('keeps incomplete streaming markdown visible without crashing', async () => {
15+
render(<MarkdownContent content={'```ts\nconst a = 1'} streaming />)
16+
expect(await screen.findByText(/const a = 1/)).toBeTruthy()
17+
})
18+
19+
it('renders strong text, inline code and fenced code blocks', async () => {
20+
const { container } = render(
21+
<MarkdownContent content={'**加粗** `inline` \n\n```ts\nconst v = 1\n```'} />,
22+
)
23+
expect(await screen.findByText('加粗')).toBeTruthy()
24+
expect(await screen.findByText('inline')).toBeTruthy()
25+
26+
expect(container.querySelector('[data-streamdown="strong"]')).toBeTruthy()
27+
expect(container.querySelector('[data-streamdown="inline-code"]')).toBeTruthy()
28+
expect(container.querySelector('[data-streamdown="code-block"]')).toBeTruthy()
29+
expect(container.querySelector('[data-streamdown="code-block-header"]')).toBeTruthy()
30+
expect(container.querySelector('[data-streamdown="code-block-copy-button"]')).toBeTruthy()
31+
})
32+
})
Lines changed: 63 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,122 +1,84 @@
1-
import { useMemo } from 'react'
2-
import ReactMarkdown from 'react-markdown'
3-
import remarkGfm from 'remark-gfm'
4-
import CodeBlock from './CodeBlock'
1+
import {
2+
Check,
3+
Copy,
4+
Download,
5+
ExternalLink,
6+
type LucideIcon,
7+
Loader2,
8+
Maximize2,
9+
RotateCcw,
10+
X,
11+
ZoomIn,
12+
ZoomOut,
13+
} from 'lucide-react'
14+
import type { SVGProps } from 'react'
15+
import { code } from '@streamdown/code'
16+
import { cjk } from '@streamdown/cjk'
17+
import { Streamdown } from 'streamdown'
18+
import 'streamdown/styles.css'
519

620
interface MarkdownContentProps {
721
content: string
822
streaming?: boolean
923
}
1024

11-
/** 轻量级代码块:融入文本流的行内样式 */
12-
function InlineCodeBlock({ code }: { code: string }) {
13-
return (
14-
<code
15-
style={{
16-
fontFamily: 'var(--font-mono)',
17-
fontSize: '0.9em',
18-
padding: '0.15em 0.35em',
19-
borderRadius: 'var(--radius-sm)',
20-
background: 'var(--bg-tertiary)',
21-
color: 'var(--text-primary)',
22-
}}
23-
>
24-
{code}
25-
</code>
26-
)
27-
}
28-
29-
/** 将代码块映射到 CodeBlock 组件 */
30-
function CodeComponent({
31-
inline,
32-
className,
33-
children,
34-
...props
35-
}: {
36-
inline?: boolean
37-
className?: string
38-
children?: React.ReactNode
39-
}) {
40-
if (inline) {
41-
return (
42-
<code className="markdown-inline-code" {...props}>
43-
{children}
44-
</code>
45-
)
46-
}
25+
const streamdownPlugins = { code, cjk }
4726

48-
const match = /language-(\w+)/.exec(className || '')
49-
const language = match ? match[1] : 'text'
50-
const code = String(children).replace(/\n$/, '')
51-
const lines = code.split('\n')
27+
type StreamdownIconProps = SVGProps<SVGSVGElement> & { size?: number }
5228

53-
// 单行且无明确语言的代码块降级为轻量渲染
54-
if (lines.length <= 1 && language === 'text') {
55-
return <InlineCodeBlock code={code} />
29+
// 将 lucide 图标的 size 收敛为 number,匹配 streamdown 的 IconMap 类型约束。
30+
function adaptIcon(Icon: LucideIcon) {
31+
return ({ size, ...props }: StreamdownIconProps) => {
32+
const normalizedSize = typeof size === 'number' ? size : undefined
33+
return <Icon {...props} size={normalizedSize} />
5634
}
57-
58-
return <CodeBlock code={code} language={language} />
5935
}
6036

61-
function splitStreamingContent(content: string): { completed: string; pending: string } {
62-
if (!content) return { completed: '', pending: '' }
63-
64-
// 1. 检测未闭合的代码块
65-
const fenceMatches = content.match(/```/g)
66-
if (fenceMatches && fenceMatches.length % 2 === 1) {
67-
const lastFenceIdx = content.lastIndexOf('```')
68-
return {
69-
completed: content.slice(0, lastFenceIdx),
70-
pending: content.slice(lastFenceIdx),
71-
}
72-
}
73-
74-
// 2. 不在代码块中,按段落分割
75-
const lastDoubleNewline = content.lastIndexOf('\n\n')
76-
if (lastDoubleNewline !== -1) {
77-
if (lastDoubleNewline >= content.length - 2) {
78-
// \n\n 在末尾,找上一段
79-
const prevDoubleNewline = content.lastIndexOf('\n\n', lastDoubleNewline - 1)
80-
if (prevDoubleNewline !== -1) {
81-
return {
82-
completed: content.slice(0, prevDoubleNewline + 2),
83-
pending: content.slice(prevDoubleNewline + 2),
84-
}
85-
}
86-
return { completed: '', pending: content }
87-
}
88-
return {
89-
completed: content.slice(0, lastDoubleNewline + 2),
90-
pending: content.slice(lastDoubleNewline + 2),
91-
}
92-
}
93-
94-
// 3. 单段:包含完整行内语法或较长时尝试渲染
95-
const hasBold = /\*\*[^*\n]+\*\*/.test(content)
96-
const hasCode = /`[^`\n]+`/.test(content)
97-
const hasItalic = /(?<!\*)\*[^*\n]+\*(?!\*)/.test(content)
98-
if (hasBold || hasCode || hasItalic || content.length > 300) {
99-
return { completed: content, pending: '' }
100-
}
37+
const streamdownIcons = {
38+
CheckIcon: adaptIcon(Check),
39+
CopyIcon: adaptIcon(Copy),
40+
DownloadIcon: adaptIcon(Download),
41+
ExternalLinkIcon: adaptIcon(ExternalLink),
42+
Loader2Icon: adaptIcon(Loader2),
43+
Maximize2Icon: adaptIcon(Maximize2),
44+
RotateCcwIcon: adaptIcon(RotateCcw),
45+
XIcon: adaptIcon(X),
46+
ZoomInIcon: adaptIcon(ZoomIn),
47+
ZoomOutIcon: adaptIcon(ZoomOut),
48+
}
10149

102-
return { completed: '', pending: content }
50+
const streamdownTranslations = {
51+
copyCode: '复制代码',
52+
copied: '已复制',
53+
copyLink: '复制链接',
54+
openExternalLink: '打开外部链接?',
55+
externalLinkWarning: '你即将访问外部网站。',
56+
close: '关闭',
57+
downloadFile: '下载文件',
58+
viewFullscreen: '全屏查看',
59+
exitFullscreen: '退出全屏',
10360
}
10461

10562
/** Markdown 渲染器,支持 GFM;流式输出时分段增量渲染 */
10663
export default function MarkdownContent({ content, streaming }: MarkdownContentProps) {
107-
const { completed, pending } = useMemo(
108-
() => (streaming ? splitStreamingContent(content) : { completed: content, pending: '' }),
109-
[content, streaming],
110-
)
111-
11264
return (
11365
<div className="markdown-body">
114-
{completed && (
115-
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ code: CodeComponent as any }}>
116-
{completed}
117-
</ReactMarkdown>
118-
)}
119-
{pending && <span style={{ whiteSpace: 'pre-wrap' }}>{pending}</span>}
66+
<Streamdown
67+
className="markdown-streamdown"
68+
mode={streaming ? 'streaming' : 'static'}
69+
parseIncompleteMarkdown={!!streaming}
70+
controls={{
71+
code: { copy: true, download: false },
72+
table: false,
73+
mermaid: false,
74+
}}
75+
plugins={streamdownPlugins}
76+
icons={streamdownIcons}
77+
translations={streamdownTranslations}
78+
isAnimating={!!streaming}
79+
>
80+
{content || ''}
81+
</Streamdown>
12082
</div>
12183
)
12284
}

web/src/components/chat/MessageItem.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,20 +232,21 @@ const styles: Record<string, React.CSSProperties> = {
232232
display: 'flex',
233233
justifyContent: 'flex-end',
234234
alignItems: 'flex-start',
235-
padding: '8px 0',
235+
padding: '12px 0 10px',
236236
position: 'relative',
237237
gap: 6,
238238
},
239239
userContent: {
240240
maxWidth: '85%',
241241
},
242242
userBubble: {
243-
background: 'var(--user-msg-bg)',
244-
color: 'var(--text-primary)',
243+
background: 'var(--user-bubble)',
244+
color: 'var(--user-bubble-text)',
245245
padding: '10px 14px',
246246
borderRadius: 'var(--radius-lg)',
247247
fontSize: 14,
248248
lineHeight: 1.6,
249+
border: '1px solid var(--border-primary)',
249250
textWrap: 'pretty' as any,
250251
},
251252
revertBtn: {
@@ -266,7 +267,7 @@ const styles: Record<string, React.CSSProperties> = {
266267
aiRow: {
267268
display: 'flex',
268269
gap: 10,
269-
padding: '8px 0',
270+
padding: '8px 0 10px',
270271
},
271272
aiRowGrouped: {
272273
display: 'flex',

web/src/components/chat/TodoStrip.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ export default function TodoStrip() {
5454
const total = items.length
5555
const hasFailure = failedCount > 0
5656
const allDone = total > 0 && completedCount === total
57+
const requiredTotal = summary?.required_total ?? total
58+
const requiredCompleted = summary?.required_completed ?? completedCount
59+
const progressRatio = requiredTotal > 0 ? Math.min(1, requiredCompleted / requiredTotal) : 0
60+
const showProgress = !conflict && requiredTotal > 0
61+
const progressIndeterminate = !!inProgress && !allDone
5762

5863
// 冲突态强制展开
5964
const effectiveExpanded = expanded || !!conflict
@@ -119,6 +124,15 @@ export default function TodoStrip() {
119124
{effectiveExpanded ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
120125
</span>
121126
</button>
127+
{showProgress && (
128+
<div style={styles.progressTrack} aria-hidden>
129+
{progressIndeterminate ? (
130+
<span style={styles.progressIndeterminate} />
131+
) : (
132+
<span style={{ ...styles.progressFill, width: `${Math.round(progressRatio * 100)}%` }} />
133+
)}
134+
</div>
135+
)}
122136

123137
{effectiveExpanded && (
124138
<div style={styles.body}>
@@ -259,6 +273,30 @@ const styles: Record<string, React.CSSProperties> = {
259273
flexShrink: 0,
260274
color: 'var(--text-tertiary)',
261275
},
276+
progressTrack: {
277+
height: 3,
278+
background: 'var(--bg-active)',
279+
overflow: 'hidden',
280+
position: 'relative',
281+
},
282+
progressFill: {
283+
display: 'block',
284+
height: '100%',
285+
borderRadius: '0 var(--radius-full) var(--radius-full) 0',
286+
background: 'linear-gradient(90deg, var(--accent), var(--accent-hover))',
287+
transition: 'width 0.25s ease-out',
288+
},
289+
progressIndeterminate: {
290+
position: 'absolute',
291+
top: 0,
292+
left: 0,
293+
display: 'block',
294+
width: '45%',
295+
height: '100%',
296+
borderRadius: 'var(--radius-full)',
297+
background: 'linear-gradient(90deg, rgba(41,151,255,0), var(--accent), rgba(41,151,255,0))',
298+
animation: 'todo-indeterminate 1.1s linear infinite',
299+
},
262300
body: {
263301
padding: '8px 12px 10px',
264302
borderTop: '1px solid var(--border-primary)',

0 commit comments

Comments
 (0)