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

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:
iven
2026-04-01 22:03:07 +08:00
parent e3b93ff96d
commit 73ff5e8c5e
43 changed files with 4817 additions and 905 deletions

View 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>
);
}