feat(desktop): DeerFlow visual redesign + stream hang fix + intelligence client
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
DeerFlow frontend visual overhaul: - Card-style input box (white rounded card, textarea top, actions bottom) - Dropdown mode selector (闪速/思考/Pro/Ultra with icons+descriptions) - Colored quick-action chips (小惊喜/写作/研究/收集/学习) - Minimal top bar (title + token count + export) - Warm gray color system (#faf9f6 bg, #f5f4f1 sidebar, #e8e6e1 border) - DeerFlow-style sidebar (新对话/对话/智能体 nav) - Reasoning block, tool call chain, task progress visualization - Streaming text, model selector, suggestion chips components - Resizable artifact panel with drag handle - Virtualized message list for 100+ messages Bug fixes: - Stream hang: GatewayClient onclose code 1000 now calls onComplete - WebView2 textarea border: CSS !important override for UA styles - Gateway stream event handling (response/phase/tool_call types) Intelligence client: - Unified client with fallback drivers (compactor/heartbeat/identity/memory/reflection) - Gateway API types and type conversions
This commit is contained in:
136
desktop/src/components/ai/StreamingText.tsx
Normal file
136
desktop/src/components/ai/StreamingText.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useMemo, useRef, useEffect, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
interface StreamingTextProps {
|
||||
content: string;
|
||||
isStreaming: boolean;
|
||||
className?: string;
|
||||
/** Render as markdown for completed messages */
|
||||
asMarkdown?: boolean;
|
||||
}
|
||||
|
||||
// Split text into words at whitespace and CJK character boundaries
|
||||
function splitIntoTokens(text: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
|
||||
for (const char of text) {
|
||||
const code = char.codePointAt(0);
|
||||
const isCJK = code && (
|
||||
(code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified Ideographs
|
||||
(code >= 0x3400 && code <= 0x4DBF) || // CJK Extension A
|
||||
(code >= 0x3000 && code <= 0x303F) || // CJK Symbols and Punctuation
|
||||
(code >= 0xFF00 && code <= 0xFFEF) || // Fullwidth Forms
|
||||
(code >= 0x2E80 && code <= 0x2EFF) || // CJK Radicals Supplement
|
||||
(code >= 0xF900 && code <= 0xFAFF) // CJK Compatibility Ideographs
|
||||
);
|
||||
const isWhitespace = /\s/.test(char);
|
||||
|
||||
if (isCJK) {
|
||||
// CJK chars are individual tokens
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
tokens.push(char);
|
||||
} else if (isWhitespace) {
|
||||
current += char;
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function StreamingText({
|
||||
content,
|
||||
isStreaming,
|
||||
className = '',
|
||||
asMarkdown = true,
|
||||
}: StreamingTextProps) {
|
||||
// For completed messages, use full markdown rendering
|
||||
if (!isStreaming && asMarkdown) {
|
||||
return (
|
||||
<div className={`prose-sm prose-gray dark:prose-invert max-w-none ${className}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For streaming messages, use token-by-token animation
|
||||
if (isStreaming && content) {
|
||||
return (
|
||||
<StreamingTokenText content={content} className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
// Empty streaming - show nothing
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-by-token streaming text with CSS animation.
|
||||
* Each token (word/CJK char) fades in sequentially.
|
||||
*/
|
||||
function StreamingTokenText({ content, className }: { content: string; className: string }) {
|
||||
const tokens = useMemo(() => splitIntoTokens(content), [content]);
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(0);
|
||||
|
||||
// Animate tokens appearing
|
||||
useEffect(() => {
|
||||
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));
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(timer);
|
||||
}, [tokens.length, visibleCount]);
|
||||
|
||||
// Reset visible count when content changes significantly
|
||||
useEffect(() => {
|
||||
setVisibleCount(tokens.length);
|
||||
}, [tokens.length]);
|
||||
|
||||
return (
|
||||
<span ref={containerRef} className={`whitespace-pre-wrap ${className}`}>
|
||||
{tokens.map((token, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="streaming-token"
|
||||
style={{
|
||||
opacity: i < visibleCount ? 1 : 0,
|
||||
transition: 'opacity 0.15s ease-in',
|
||||
}}
|
||||
>
|
||||
{token}
|
||||
</span>
|
||||
))}
|
||||
<span className="inline-block w-1.5 h-4 bg-orange-500 animate-pulse ml-0.5 align-text-bottom rounded-sm" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user