fix(ui): button overlap + Markdown rendering (BUG-012, BUG-013)
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
BUG-012: Move side panel toggle button below header (top-3 → top-[52px]) to avoid overlap with "详情" button in chat header. BUG-013: Add rich Markdown component overrides to StreamingText: - Code blocks: dark bg, border, rounded, overflow-x-auto - Inline code: subtle bg highlight - Tables: full borders, alternating header bg, proper padding - Lists: disc/decimal markers, spacing - Headings: proper hierarchy sizes - Blockquotes: left border + subtle bg - Links: blue underlined with hover
This commit is contained in:
@@ -66,7 +66,7 @@ export function ResizableChatLayout({
|
||||
{chatPanel}
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="absolute top-3 right-3 z-10 p-1.5 rounded-md bg-white/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-white dark:hover:bg-gray-800 transition-colors shadow-sm"
|
||||
className="absolute top-[52px] right-3 z-10 p-1.5 rounded-md bg-white/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-white dark:hover:bg-gray-800 transition-colors shadow-sm"
|
||||
title="打开侧面板"
|
||||
>
|
||||
<PanelRightOpen className="w-4 h-4" />
|
||||
@@ -91,7 +91,7 @@ export function ResizableChatLayout({
|
||||
{chatPanel}
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="absolute top-3 right-3 z-10 p-1.5 rounded-md bg-white/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-white dark:hover:bg-gray-800 transition-colors shadow-sm"
|
||||
className="absolute top-[52px] right-3 z-10 p-1.5 rounded-md bg-white/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-white dark:hover:bg-gray-800 transition-colors shadow-sm"
|
||||
title="关闭侧面板"
|
||||
>
|
||||
<PanelRightClose className="w-4 h-4" />
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { useMemo, useRef, useEffect, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { Components } from 'react-markdown';
|
||||
|
||||
/**
|
||||
* Streaming text with word-by-word reveal animation.
|
||||
*
|
||||
* Inspired by DeerFlow's Streamdown library:
|
||||
* - Splits streaming text into "words" at whitespace and CJK boundaries
|
||||
* - Each word gets a CSS fade-in animation
|
||||
* - Historical messages render statically (no animation overhead)
|
||||
*
|
||||
* For non-streaming content, falls back to react-markdown for full
|
||||
* markdown rendering including GFM tables, strikethrough, etc.
|
||||
* For completed messages, renders full Markdown with styled components.
|
||||
* For streaming messages, uses token-by-token animation.
|
||||
*/
|
||||
|
||||
interface StreamingTextProps {
|
||||
@@ -22,6 +18,115 @@ interface StreamingTextProps {
|
||||
asMarkdown?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown component overrides for rich rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const markdownComponents: Components = {
|
||||
// Code blocks (```...```)
|
||||
pre({ children }) {
|
||||
return (
|
||||
<pre className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto text-sm leading-relaxed border border-gray-200 dark:border-gray-700 my-3">
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
// Inline code (`...`)
|
||||
code({ className, children, ...props }) {
|
||||
// If it has a language class, it's inside a code block — render as block
|
||||
const isBlock = className?.startsWith('language-');
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className={`${className || ''} text-gray-800 dark:text-gray-200`} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 px-1.5 py-0.5 rounded text-[0.9em] font-mono" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
// Tables
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-3 -mx-1">
|
||||
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700 rounded-lg text-sm">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className="bg-gray-50 dark:bg-gray-800/50">{children}</thead>;
|
||||
},
|
||||
th({ children }) {
|
||||
return (
|
||||
<th className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
},
|
||||
td({ children }) {
|
||||
return (
|
||||
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-gray-600 dark:text-gray-400">
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
},
|
||||
// Unordered lists
|
||||
ul({ children }) {
|
||||
return <ul className="list-disc list-outside ml-5 my-2 space-y-1">{children}</ul>;
|
||||
},
|
||||
// Ordered lists
|
||||
ol({ children }) {
|
||||
return <ol className="list-decimal list-outside ml-5 my-2 space-y-1">{children}</ol>;
|
||||
},
|
||||
// List items
|
||||
li({ children }) {
|
||||
return <li className="leading-relaxed">{children}</li>;
|
||||
},
|
||||
// Headings
|
||||
h1({ children }) {
|
||||
return <h1 className="text-xl font-bold mt-5 mb-3 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h1>;
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h2 className="text-lg font-bold mt-4 mb-2 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h2>;
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h3 className="text-base font-semibold mt-3 mb-2 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h3>;
|
||||
},
|
||||
// Blockquotes
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 py-1 my-3 text-gray-600 dark:text-gray-400 italic bg-gray-50 dark:bg-gray-800/30 rounded-r-lg">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
// Paragraphs
|
||||
p({ children }) {
|
||||
return <p className="my-2 leading-relaxed first:mt-0 last:mb-0">{children}</p>;
|
||||
},
|
||||
// Links
|
||||
a({ href, children }) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 underline hover:text-blue-800 dark:hover:text-blue-300">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Horizontal rules
|
||||
hr() {
|
||||
return <hr className="my-4 border-gray-200 dark:border-gray-700" />;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token splitter for streaming animation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Split text into words at whitespace and CJK character boundaries
|
||||
function splitIntoTokens(text: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
@@ -40,7 +145,6 @@ function splitIntoTokens(text: string): string[] {
|
||||
const isWhitespace = /\s/.test(char);
|
||||
|
||||
if (isCJK) {
|
||||
// CJK chars are individual tokens
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
@@ -60,17 +164,21 @@ function splitIntoTokens(text: string): string[] {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function StreamingText({
|
||||
content,
|
||||
isStreaming,
|
||||
className = '',
|
||||
asMarkdown = true,
|
||||
}: StreamingTextProps) {
|
||||
// For completed messages, use full markdown rendering
|
||||
// For completed messages, use full markdown rendering with styled components
|
||||
if (!isStreaming && asMarkdown) {
|
||||
return (
|
||||
<div className={`prose-sm prose-gray dark:prose-invert max-w-none ${className}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
@@ -102,7 +210,6 @@ function StreamingTokenText({ content, className }: { content: string; className
|
||||
if (visibleCount >= tokens.length) return;
|
||||
|
||||
const remaining = tokens.length - visibleCount;
|
||||
// Batch reveal: show multiple tokens per frame for fast streaming
|
||||
const batchSize = Math.min(remaining, 3);
|
||||
const timer = requestAnimationFrame(() => {
|
||||
setVisibleCount(prev => Math.min(prev + batchSize, tokens.length));
|
||||
|
||||
Reference in New Issue
Block a user