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}
|
{chatPanel}
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
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="打开侧面板"
|
title="打开侧面板"
|
||||||
>
|
>
|
||||||
<PanelRightOpen className="w-4 h-4" />
|
<PanelRightOpen className="w-4 h-4" />
|
||||||
@@ -91,7 +91,7 @@ export function ResizableChatLayout({
|
|||||||
{chatPanel}
|
{chatPanel}
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
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="关闭侧面板"
|
title="关闭侧面板"
|
||||||
>
|
>
|
||||||
<PanelRightClose className="w-4 h-4" />
|
<PanelRightClose className="w-4 h-4" />
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { useMemo, useRef, useEffect, useState } from 'react';
|
import { useMemo, useRef, useEffect, useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import type { Components } from 'react-markdown';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streaming text with word-by-word reveal animation.
|
* Streaming text with word-by-word reveal animation.
|
||||||
*
|
*
|
||||||
* Inspired by DeerFlow's Streamdown library:
|
* For completed messages, renders full Markdown with styled components.
|
||||||
* - Splits streaming text into "words" at whitespace and CJK boundaries
|
* For streaming messages, uses token-by-token animation.
|
||||||
* - 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface StreamingTextProps {
|
interface StreamingTextProps {
|
||||||
@@ -22,6 +18,115 @@ interface StreamingTextProps {
|
|||||||
asMarkdown?: boolean;
|
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
|
// Split text into words at whitespace and CJK character boundaries
|
||||||
function splitIntoTokens(text: string): string[] {
|
function splitIntoTokens(text: string): string[] {
|
||||||
const tokens: string[] = [];
|
const tokens: string[] = [];
|
||||||
@@ -40,7 +145,6 @@ function splitIntoTokens(text: string): string[] {
|
|||||||
const isWhitespace = /\s/.test(char);
|
const isWhitespace = /\s/.test(char);
|
||||||
|
|
||||||
if (isCJK) {
|
if (isCJK) {
|
||||||
// CJK chars are individual tokens
|
|
||||||
if (current) {
|
if (current) {
|
||||||
tokens.push(current);
|
tokens.push(current);
|
||||||
current = '';
|
current = '';
|
||||||
@@ -60,17 +164,21 @@ function splitIntoTokens(text: string): string[] {
|
|||||||
return tokens;
|
return tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function StreamingText({
|
export function StreamingText({
|
||||||
content,
|
content,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
className = '',
|
className = '',
|
||||||
asMarkdown = true,
|
asMarkdown = true,
|
||||||
}: StreamingTextProps) {
|
}: StreamingTextProps) {
|
||||||
// For completed messages, use full markdown rendering
|
// For completed messages, use full markdown rendering with styled components
|
||||||
if (!isStreaming && asMarkdown) {
|
if (!isStreaming && asMarkdown) {
|
||||||
return (
|
return (
|
||||||
<div className={`prose-sm prose-gray dark:prose-invert max-w-none ${className}`}>
|
<div className={`prose-sm prose-gray dark:prose-invert max-w-none ${className}`}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,7 +210,6 @@ function StreamingTokenText({ content, className }: { content: string; className
|
|||||||
if (visibleCount >= tokens.length) return;
|
if (visibleCount >= tokens.length) return;
|
||||||
|
|
||||||
const remaining = tokens.length - visibleCount;
|
const remaining = tokens.length - visibleCount;
|
||||||
// Batch reveal: show multiple tokens per frame for fast streaming
|
|
||||||
const batchSize = Math.min(remaining, 3);
|
const batchSize = Math.min(remaining, 3);
|
||||||
const timer = requestAnimationFrame(() => {
|
const timer = requestAnimationFrame(() => {
|
||||||
setVisibleCount(prev => Math.min(prev + batchSize, tokens.length));
|
setVisibleCount(prev => Math.min(prev + batchSize, tokens.length));
|
||||||
|
|||||||
Reference in New Issue
Block a user