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:
302
desktop/src/components/ai/ArtifactPanel.tsx
Normal file
302
desktop/src/components/ai/ArtifactPanel.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
FileCode2,
|
||||
Table2,
|
||||
Image as ImageIcon,
|
||||
Download,
|
||||
Copy,
|
||||
ChevronLeft,
|
||||
File,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ArtifactFile {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'markdown' | 'code' | 'table' | 'image' | 'text';
|
||||
content: string;
|
||||
language?: string;
|
||||
createdAt: Date;
|
||||
sourceStepId?: string; // Links to ToolCallStep that created this artifact
|
||||
}
|
||||
|
||||
interface ArtifactPanelProps {
|
||||
artifacts: ArtifactFile[];
|
||||
selectedId?: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getFileIcon(type: ArtifactFile['type']) {
|
||||
switch (type) {
|
||||
case 'markdown': return FileText;
|
||||
case 'code': return FileCode2;
|
||||
case 'table': return Table2;
|
||||
case 'image': return ImageIcon;
|
||||
default: return File;
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(type: ArtifactFile['type']): string {
|
||||
switch (type) {
|
||||
case 'markdown': return 'MD';
|
||||
case 'code': return 'CODE';
|
||||
case 'table': return 'TABLE';
|
||||
case 'image': return 'IMG';
|
||||
default: return 'TXT';
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeColor(type: ArtifactFile['type']): string {
|
||||
switch (type) {
|
||||
case 'markdown': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300';
|
||||
case 'code': return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300';
|
||||
case 'table': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300';
|
||||
case 'image': return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300';
|
||||
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ArtifactPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ArtifactPanel({
|
||||
artifacts,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onClose: _onClose,
|
||||
className = '',
|
||||
}: ArtifactPanelProps) {
|
||||
const [viewMode, setViewMode] = useState<'preview' | 'code'>('preview');
|
||||
const selected = useMemo(
|
||||
() => artifacts.find((a) => a.id === selectedId),
|
||||
[artifacts, selectedId]
|
||||
);
|
||||
|
||||
// List view when no artifact is selected
|
||||
if (!selected) {
|
||||
return (
|
||||
<div className={`h-full flex flex-col ${className}`}>
|
||||
<div className="p-4 flex-1 overflow-y-auto custom-scrollbar">
|
||||
{artifacts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500">
|
||||
<FileText className="w-8 h-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">暂无产物文件</p>
|
||||
<p className="text-xs mt-1">Agent 生成文件后将在此显示</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{artifacts.map((artifact) => {
|
||||
const Icon = getFileIcon(artifact.type);
|
||||
return (
|
||||
<button
|
||||
key={artifact.id}
|
||||
onClick={() => onSelect(artifact.id)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors text-left group"
|
||||
>
|
||||
<Icon className="w-5 h-5 text-gray-400 flex-shrink-0 group-hover:text-orange-500 transition-colors" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-200 truncate">
|
||||
{artifact.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${getTypeColor(artifact.type)}`}>
|
||||
{getTypeLabel(artifact.type)}
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{new Date(artifact.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Detail view
|
||||
const Icon = getFileIcon(selected.type);
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-col ${className}`}>
|
||||
{/* File header */}
|
||||
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => onSelect('')}
|
||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
title="返回文件列表"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<Icon className="w-4 h-4 text-orange-500 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200 truncate flex-1">
|
||||
{selected.name}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${getTypeColor(selected.type)}`}>
|
||||
{getTypeLabel(selected.type)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* View mode toggle */}
|
||||
<div className="px-4 py-1.5 border-b border-gray-100 dark:border-gray-800 flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setViewMode('preview')}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
|
||||
viewMode === 'preview'
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
预览
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('code')}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
|
||||
viewMode === 'code'
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
源码
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4">
|
||||
{viewMode === 'preview' ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
{selected.type === 'markdown' ? (
|
||||
<MarkdownPreview content={selected.content} />
|
||||
) : selected.type === 'code' ? (
|
||||
<pre className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 text-xs font-mono overflow-x-auto text-gray-700 dark:text-gray-200">
|
||||
{selected.content}
|
||||
</pre>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-200">
|
||||
{selected.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 text-xs font-mono overflow-x-auto text-gray-700 dark:text-gray-200 leading-relaxed">
|
||||
{selected.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="px-4 py-2 border-t border-gray-200 dark:border-gray-700 flex items-center gap-2 flex-shrink-0">
|
||||
<ActionButton
|
||||
icon={<Copy className="w-3.5 h-3.5" />}
|
||||
label="复制"
|
||||
onClick={() => navigator.clipboard.writeText(selected.content)}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<Download className="w-3.5 h-3.5" />}
|
||||
label="下载"
|
||||
onClick={() => downloadArtifact(selected)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ActionButton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ActionButton({ icon, label, onClick }: { icon: React.ReactNode; label: string; onClick: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
onClick();
|
||||
if (label === '复制') {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{copied ? <span className="text-green-500 text-xs">已复制</span> : icon}
|
||||
{!copied && label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simple Markdown preview (no external deps)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MarkdownPreview({ content }: { content: string }) {
|
||||
// Basic markdown rendering: headings, bold, code blocks, lists
|
||||
const lines = content.split('\n');
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{lines.map((line, i) => {
|
||||
// Heading
|
||||
if (line.startsWith('### ')) {
|
||||
return <h3 key={i} className="text-sm font-bold text-gray-800 dark:text-gray-100 mt-3">{line.slice(4)}</h3>;
|
||||
}
|
||||
if (line.startsWith('## ')) {
|
||||
return <h2 key={i} className="text-base font-bold text-gray-800 dark:text-gray-100 mt-4">{line.slice(3)}</h2>;
|
||||
}
|
||||
if (line.startsWith('# ')) {
|
||||
return <h1 key={i} className="text-lg font-bold text-gray-800 dark:text-gray-100">{line.slice(2)}</h1>;
|
||||
}
|
||||
// Code block (simplified)
|
||||
if (line.startsWith('```')) return null;
|
||||
// List item
|
||||
if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
return <li key={i} className="text-sm text-gray-700 dark:text-gray-300 ml-4">{renderInline(line.slice(2))}</li>;
|
||||
}
|
||||
// Empty line
|
||||
if (!line.trim()) return <div key={i} className="h-2" />;
|
||||
// Regular paragraph
|
||||
return <p key={i} className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{renderInline(line)}</p>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderInline(text: string): React.ReactNode {
|
||||
// Bold
|
||||
const parts = text.split(/\*\*(.*?)\*\*/g);
|
||||
return parts.map((part, i) =>
|
||||
i % 2 === 1 ? <strong key={i} className="font-semibold">{part}</strong> : part
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function downloadArtifact(artifact: ArtifactFile) {
|
||||
const blob = new Blob([artifact.content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = artifact.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
133
desktop/src/components/ai/ChatMode.tsx
Normal file
133
desktop/src/components/ai/ChatMode.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Zap, Lightbulb, GraduationCap, Rocket, Check } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* Chat interaction mode selector — DeerFlow-style dropdown.
|
||||
*
|
||||
* A single trigger button in the input bar that opens an upward dropdown
|
||||
* showing each mode with icon, title, description, and checkmark.
|
||||
*/
|
||||
|
||||
export type ChatModeType = 'flash' | 'thinking' | 'pro' | 'ultra';
|
||||
|
||||
export interface ChatModeConfig {
|
||||
thinking_enabled: boolean;
|
||||
reasoning_effort?: 'low' | 'medium' | 'high';
|
||||
plan_mode?: boolean;
|
||||
subagent_enabled?: boolean;
|
||||
}
|
||||
|
||||
export const CHAT_MODES: Record<ChatModeType, { label: string; icon: typeof Zap; config: ChatModeConfig; description: string }> = {
|
||||
flash: {
|
||||
label: '闪速',
|
||||
icon: Zap,
|
||||
config: { thinking_enabled: false },
|
||||
description: '快速且高效的完成任务,但可能不够精准',
|
||||
},
|
||||
thinking: {
|
||||
label: '思考',
|
||||
icon: Lightbulb,
|
||||
config: { thinking_enabled: true, reasoning_effort: 'low' },
|
||||
description: '启用推理,低强度思考',
|
||||
},
|
||||
pro: {
|
||||
label: 'Pro',
|
||||
icon: GraduationCap,
|
||||
config: { thinking_enabled: true, reasoning_effort: 'medium', plan_mode: true },
|
||||
description: '思考、计划再执行,获得更精准的结果,可能需要更多时间',
|
||||
},
|
||||
ultra: {
|
||||
label: 'Ultra',
|
||||
icon: Rocket,
|
||||
config: { thinking_enabled: true, reasoning_effort: 'high', plan_mode: true, subagent_enabled: true },
|
||||
description: '继承自 Pro 模式,可调用子代理分工协作,适合复杂多步骤任务,能力最强',
|
||||
},
|
||||
};
|
||||
|
||||
interface ChatModeProps {
|
||||
value: ChatModeType;
|
||||
onChange: (mode: ChatModeType) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ChatMode({ value, onChange, disabled = false }: ChatModeProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const current = CHAT_MODES[value];
|
||||
const Icon = current.icon;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
onClick={() => { if (!disabled) setOpen(!open); }}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span>{current.label}</span>
|
||||
</button>
|
||||
|
||||
{/* Dropdown — pops up above the input bar */}
|
||||
<AnimatePresence>
|
||||
{open && !disabled && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 4, scale: 0.95 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="absolute bottom-full left-0 mb-2 w-80 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 py-2 z-50"
|
||||
>
|
||||
<div className="px-3 py-2 text-xs text-gray-400 font-medium">模式</div>
|
||||
<div className="space-y-1">
|
||||
{(Object.entries(CHAT_MODES) as [ChatModeType, typeof CHAT_MODES.flash][]).map(([mode, def]) => {
|
||||
const ModeIcon = def.icon;
|
||||
const isActive = value === mode;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => {
|
||||
onChange(mode);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 flex items-start gap-3 transition-colors"
|
||||
>
|
||||
<div className="mt-0.5">
|
||||
<ModeIcon className={`w-4 h-4 ${isActive ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`font-medium text-sm ${isActive ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||
{def.label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<Check className="w-3.5 h-3.5 text-gray-900 dark:text-white" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{def.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
desktop/src/components/ai/Conversation.tsx
Normal file
117
desktop/src/components/ai/Conversation.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useRef, useEffect, useState, createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConversationContext — shared state for child ai-elements components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ConversationContextValue {
|
||||
isStreaming: boolean;
|
||||
setIsStreaming: (v: boolean) => void;
|
||||
messages: unknown[];
|
||||
setMessages: (msgs: unknown[]) => void;
|
||||
}
|
||||
|
||||
const ConversationContext = createContext<ConversationContextValue | null>(null);
|
||||
|
||||
export function useConversationContext() {
|
||||
const ctx = useContext(ConversationContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useConversationContext must be used within ConversationProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ConversationProvider({ children }: { children: ReactNode }) {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [messages, setMessages] = useState<unknown[]>([]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ isStreaming, setIsStreaming, messages, setMessages }),
|
||||
[isStreaming, messages],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConversationContext.Provider value={value}>
|
||||
{children}
|
||||
</ConversationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversation container with auto-stick-to-bottom scroll behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Conversation container with auto-stick-to-bottom scroll behavior.
|
||||
*
|
||||
* Inspired by DeerFlow's use-stick-to-bottom pattern:
|
||||
* - Stays pinned to bottom during streaming
|
||||
* - Remembers user's scroll position when they scroll up
|
||||
* - Auto-scrolls back to bottom on new content when near the bottom
|
||||
*/
|
||||
interface ConversationProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SCROLL_THRESHOLD = 80; // px from bottom to consider "at bottom"
|
||||
|
||||
export function Conversation({ children, className = '' }: ConversationProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
// Track whether user is near the bottom
|
||||
const handleScroll = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
isAtBottomRef.current = distanceFromBottom < SCROLL_THRESHOLD;
|
||||
};
|
||||
|
||||
// Auto-scroll to bottom when content changes and user is at bottom
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
observerRef.current = new ResizeObserver(() => {
|
||||
if (isAtBottomRef.current) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
observerRef.current.observe(el);
|
||||
|
||||
return () => {
|
||||
observerRef.current?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Also observe child list changes (new messages)
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
if (isAtBottomRef.current) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
mutationObserver.observe(el, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
mutationObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className={`overflow-y-auto custom-scrollbar ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
desktop/src/components/ai/ModelSelector.tsx
Normal file
140
desktop/src/components/ai/ModelSelector.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDown, Check } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* Model selector dropdown.
|
||||
*
|
||||
* Inspired by DeerFlow's model-selector.tsx:
|
||||
* - Searchable dropdown with keyboard navigation
|
||||
* - Shows model provider badge
|
||||
* - Compact design that fits in the input area
|
||||
*/
|
||||
|
||||
interface ModelOption {
|
||||
id: string;
|
||||
name: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
interface ModelSelectorProps {
|
||||
models: ModelOption[];
|
||||
currentModel: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
models,
|
||||
currentModel,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
}: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selectedModel = models.find(m => m.id === currentModel);
|
||||
const filteredModels = search
|
||||
? models.filter(m =>
|
||||
m.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(m.provider && m.provider.toLowerCase().includes(search.toLowerCase()))
|
||||
)
|
||||
: models;
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
// Focus search on open
|
||||
useEffect(() => {
|
||||
if (open && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
onClick={() => { if (!disabled) setOpen(!open); }}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 px-2 py-1 rounded-md transition-colors disabled:opacity-50"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<span className="max-w-[120px] truncate">{selectedModel?.name || currentModel}</span>
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 4, scale: 0.95 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="absolute bottom-full right-0 mb-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 overflow-hidden"
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="p-2 border-b border-gray-100 dark:border-gray-700">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="搜索模型..."
|
||||
className="w-full bg-transparent text-xs text-gray-700 dark:text-gray-200 placeholder-gray-400 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model list */}
|
||||
<div className="max-h-48 overflow-y-auto py-1" role="listbox">
|
||||
{filteredModels.length > 0 ? (
|
||||
filteredModels.map(model => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => {
|
||||
onSelect(model.id);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={model.id === currentModel}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 text-xs flex items-center justify-between gap-2 transition-colors
|
||||
${model.id === currentModel
|
||||
? 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate font-medium">{model.name}</span>
|
||||
{model.provider && (
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">{model.provider}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.id === currentModel && (
|
||||
<Check className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-xs text-gray-400">无匹配模型</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
desktop/src/components/ai/ReasoningBlock.tsx
Normal file
156
desktop/src/components/ai/ReasoningBlock.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronRight, Lightbulb } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Collapsible reasoning/thinking block with timing display.
|
||||
*
|
||||
* Inspired by DeerFlow's reasoning display:
|
||||
* - Shows elapsed time during streaming ("Thinking for 3s...")
|
||||
* - Shows final time when complete ("Thought for 5 seconds")
|
||||
* - Animated expand/collapse
|
||||
* - Auto-collapses 1 second after streaming ends
|
||||
*/
|
||||
interface ReasoningBlockProps {
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
/** Unix timestamp (ms) when thinking started, for elapsed time display */
|
||||
startedAt?: number;
|
||||
}
|
||||
|
||||
export function ReasoningBlock({
|
||||
content,
|
||||
isStreaming = false,
|
||||
defaultExpanded = false,
|
||||
startedAt,
|
||||
}: ReasoningBlockProps) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded || isStreaming);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
|
||||
// Auto-expand when streaming starts
|
||||
useEffect(() => {
|
||||
if (isStreaming) setExpanded(true);
|
||||
}, [isStreaming]);
|
||||
|
||||
// Auto-collapse 1 second after streaming ends
|
||||
const [prevStreaming, setPrevStreaming] = useState(isStreaming);
|
||||
useEffect(() => {
|
||||
if (prevStreaming && !isStreaming && expanded) {
|
||||
const timer = setTimeout(() => setExpanded(false), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setPrevStreaming(isStreaming);
|
||||
}, [isStreaming, prevStreaming, expanded]);
|
||||
|
||||
// Timer for elapsed seconds display
|
||||
useEffect(() => {
|
||||
if (!isStreaming || !startedAt) return;
|
||||
const interval = setInterval(() => {
|
||||
setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000));
|
||||
}, 200);
|
||||
return () => clearInterval(interval);
|
||||
}, [isStreaming, startedAt]);
|
||||
|
||||
// Final duration (when streaming ends, calculate from startedAt to now)
|
||||
const durationLabel = (() => {
|
||||
if (!startedAt) return null;
|
||||
if (isStreaming) {
|
||||
return elapsedSeconds > 0 ? `已思考 ${elapsedSeconds} 秒` : '思考中...';
|
||||
}
|
||||
// Streaming finished — show "Thought for N seconds"
|
||||
const totalSec = Math.floor((Date.now() - startedAt) / 1000);
|
||||
if (totalSec <= 0) return null;
|
||||
return `思考了 ${totalSec} 秒`;
|
||||
})();
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<div className="my-2">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors group w-full text-left"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<motion.span
|
||||
animate={{ rotate: expanded ? 90 : 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</motion.span>
|
||||
<Lightbulb className="w-3.5 h-3.5 text-amber-500" />
|
||||
<span className="font-medium">思考过程</span>
|
||||
{durationLabel && !isStreaming && (
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500 ml-1">
|
||||
{durationLabel}
|
||||
</span>
|
||||
)}
|
||||
{isStreaming && (
|
||||
<span className="flex gap-0.5 ml-1">
|
||||
<span className="w-1 h-1 bg-amber-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-1 h-1 bg-amber-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-1 h-1 bg-amber-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-1.5 ml-5 pl-3 border-l-2 border-amber-300 dark:border-amber-700 text-xs text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-wrap">
|
||||
{content}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-1 h-3 bg-amber-500 animate-pulse ml-0.5 align-text-bottom rounded-sm" />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain of thought step display.
|
||||
* Shows individual reasoning steps with status indicators.
|
||||
*/
|
||||
interface ThoughtStep {
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'thinking' | 'done' | 'error';
|
||||
}
|
||||
|
||||
interface ChainOfThoughtProps {
|
||||
steps: ThoughtStep[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChainOfThought({ steps, className = '' }: ChainOfThoughtProps) {
|
||||
return (
|
||||
<div className={`ml-5 space-y-2 ${className}`}>
|
||||
{steps.map((step) => (
|
||||
<div key={step.id} className="flex items-start gap-2">
|
||||
<div className="mt-1 flex-shrink-0">
|
||||
{step.status === 'thinking' ? (
|
||||
<span className="w-2 h-2 bg-amber-400 rounded-full animate-pulse" />
|
||||
) : step.status === 'done' ? (
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
) : (
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
{step.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
desktop/src/components/ai/ResizableChatLayout.tsx
Normal file
136
desktop/src/components/ai/ResizableChatLayout.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useCallback, type ReactNode } from 'react';
|
||||
import { Group, Panel, Separator } from 'react-resizable-panels';
|
||||
import { X, PanelRightOpen, PanelRightClose } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Resizable dual-panel layout for chat + artifact/detail panel.
|
||||
*
|
||||
* Uses react-resizable-panels v4 API:
|
||||
* - Left panel: Chat area (always visible)
|
||||
* - Right panel: Artifact/detail viewer (collapsible)
|
||||
* - Draggable resize handle between panels
|
||||
* - Persisted panel sizes via localStorage
|
||||
*/
|
||||
|
||||
interface ResizableChatLayoutProps {
|
||||
chatPanel: ReactNode;
|
||||
rightPanel?: ReactNode;
|
||||
rightPanelTitle?: string;
|
||||
rightPanelOpen?: boolean;
|
||||
onRightPanelToggle?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'zclaw-layout-panels';
|
||||
const LEFT_PANEL_ID = 'chat-panel';
|
||||
const RIGHT_PANEL_ID = 'detail-panel';
|
||||
|
||||
function loadPanelSizes(): { left: string; right: string } {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed.left && parsed.right) {
|
||||
return { left: parsed.left, right: parsed.right };
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { left: '65%', right: '35%' };
|
||||
}
|
||||
|
||||
function savePanelSizes(layout: Record<string, number>) {
|
||||
try {
|
||||
const left = layout[LEFT_PANEL_ID];
|
||||
const right = layout[RIGHT_PANEL_ID];
|
||||
if (left !== undefined && right !== undefined) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ left, right }));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function ResizableChatLayout({
|
||||
chatPanel,
|
||||
rightPanel,
|
||||
rightPanelTitle = '详情',
|
||||
rightPanelOpen = false,
|
||||
onRightPanelToggle,
|
||||
}: ResizableChatLayoutProps) {
|
||||
const sizes = loadPanelSizes();
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
onRightPanelToggle?.(!rightPanelOpen);
|
||||
}, [rightPanelOpen, onRightPanelToggle]);
|
||||
|
||||
if (!rightPanelOpen || !rightPanel) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
{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"
|
||||
title="打开侧面板"
|
||||
>
|
||||
<PanelRightOpen className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Group
|
||||
orientation="horizontal"
|
||||
onLayoutChanged={(layout) => savePanelSizes(layout)}
|
||||
>
|
||||
{/* Left panel: Chat */}
|
||||
<Panel
|
||||
id={LEFT_PANEL_ID}
|
||||
defaultSize={sizes.left}
|
||||
minSize="40%"
|
||||
>
|
||||
<div className="h-full flex flex-col relative">
|
||||
{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"
|
||||
title="关闭侧面板"
|
||||
>
|
||||
<PanelRightClose className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Resize handle */}
|
||||
<Separator className="w-1.5 flex items-center justify-center group cursor-col-resize hover:bg-orange-100 dark:hover:bg-orange-900/20 transition-colors">
|
||||
<div className="w-0.5 h-8 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-orange-400 dark:group-hover:bg-orange-500 transition-colors" />
|
||||
</Separator>
|
||||
|
||||
{/* Right panel: Artifact/Detail */}
|
||||
<Panel
|
||||
id={RIGHT_PANEL_ID}
|
||||
defaultSize={sizes.right}
|
||||
minSize="25%"
|
||||
>
|
||||
<div className="h-full flex flex-col bg-gray-50 dark:bg-gray-900 border-l border-gray-200 dark:border-gray-800">
|
||||
{/* Panel header */}
|
||||
<div className="h-12 flex items-center justify-between px-4 border-b border-gray-200 dark:border-gray-800 flex-shrink-0">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
{rightPanelTitle}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
title="关闭面板"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Panel content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{rightPanel}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
48
desktop/src/components/ai/SuggestionChips.tsx
Normal file
48
desktop/src/components/ai/SuggestionChips.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* Follow-up suggestion chips.
|
||||
*
|
||||
* Inspired by DeerFlow's suggestion.tsx:
|
||||
* - Horizontal scrollable chip list
|
||||
* - Click to fill input
|
||||
* - Animated entrance
|
||||
*/
|
||||
|
||||
interface SuggestionChipsProps {
|
||||
suggestions: string[];
|
||||
onSelect: (text: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SuggestionChips({ suggestions, onSelect, className = '' }: SuggestionChipsProps) {
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-2 ${className}`}>
|
||||
{suggestions.map((text, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05, duration: 0.2 }}
|
||||
onClick={() => onSelect(text)}
|
||||
className="
|
||||
px-3 py-1.5 text-xs rounded-full
|
||||
bg-gray-50 dark:bg-gray-800
|
||||
border border-gray-200 dark:border-gray-700
|
||||
text-gray-600 dark:text-gray-400
|
||||
hover:bg-orange-50 dark:hover:bg-orange-900/20
|
||||
hover:text-orange-700 dark:hover:text-orange-300
|
||||
hover:border-orange-300 dark:hover:border-orange-600
|
||||
transition-colors
|
||||
max-w-[280px] truncate
|
||||
"
|
||||
title={text}
|
||||
>
|
||||
{text}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
desktop/src/components/ai/TaskProgress.tsx
Normal file
169
desktop/src/components/ai/TaskProgress.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState, createContext, useContext, useCallback, type ReactNode } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronRight, CheckCircle2, XCircle, Loader2, Circle } from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskContext — shared task state for sub-agent orchestration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TaskContextValue {
|
||||
tasks: Subtask[];
|
||||
updateTask: (id: string, updates: Partial<Subtask>) => void;
|
||||
}
|
||||
|
||||
const TaskContext = createContext<TaskContextValue | null>(null);
|
||||
|
||||
export function useTaskContext() {
|
||||
const ctx = useContext(TaskContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useTaskContext must be used within TaskProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function TaskProvider({
|
||||
children,
|
||||
initialTasks = [],
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialTasks?: Subtask[];
|
||||
}) {
|
||||
const [tasks, setTasks] = useState<Subtask[]>(initialTasks);
|
||||
|
||||
const updateTask = useCallback((id: string, updates: Partial<Subtask>) => {
|
||||
setTasks(prev => prev.map(t => (t.id === id ? { ...t, ...updates } : t)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TaskContext.Provider value={{ tasks, updateTask }}>
|
||||
{children}
|
||||
</TaskContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtask progress display for sub-agent orchestration.
|
||||
*
|
||||
* Inspired by DeerFlow's SubtaskCard + ShineBorder pattern:
|
||||
* - Shows task status with animated indicators
|
||||
* - Collapsible details with thinking chain
|
||||
* - Pulsing border animation for active tasks
|
||||
* - Status icons: running (pulse), completed (green), failed (red)
|
||||
*/
|
||||
|
||||
export interface Subtask {
|
||||
id: string;
|
||||
description: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
result?: string;
|
||||
error?: string;
|
||||
steps?: Array<{ content: string; status: 'thinking' | 'done' | 'error' }>;
|
||||
}
|
||||
|
||||
interface TaskProgressProps {
|
||||
tasks: Subtask[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TaskProgress({ tasks, className = '' }: TaskProgressProps) {
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{tasks.map(task => (
|
||||
<SubtaskCard key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubtaskCard({ task }: { task: Subtask }) {
|
||||
const [expanded, setExpanded] = useState(task.status === 'in_progress');
|
||||
const isActive = task.status === 'in_progress';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-lg border transition-all overflow-hidden
|
||||
${isActive
|
||||
? 'border-orange-300 dark:border-orange-700 bg-orange-50/50 dark:bg-orange-900/10 shadow-[0_0_15px_-3px_rgba(249,115,22,0.15)] dark:shadow-[0_0_15px_-3px_rgba(249,115,22,0.1)]'
|
||||
: task.status === 'completed'
|
||||
? 'border-green-200 dark:border-green-800 bg-green-50/30 dark:bg-green-900/10'
|
||||
: task.status === 'failed'
|
||||
? 'border-red-200 dark:border-red-800 bg-red-50/30 dark:bg-red-900/10'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left"
|
||||
>
|
||||
<motion.span animate={{ rotate: expanded ? 90 : 0 }} transition={{ duration: 0.15 }}>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-gray-400" />
|
||||
</motion.span>
|
||||
|
||||
{/* Status icon */}
|
||||
{task.status === 'in_progress' ? (
|
||||
<Loader2 className="w-4 h-4 text-orange-500 animate-spin" />
|
||||
) : task.status === 'completed' ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
) : task.status === 'failed' ? (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
) : (
|
||||
<Circle className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
|
||||
<span className="flex-1 text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
{task.description}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded details */}
|
||||
<AnimatePresence>
|
||||
{expanded && (task.result || task.error || (task.steps && task.steps.length > 0)) && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-3 pb-2 ml-6 border-l-2 border-gray-200 dark:border-gray-700 space-y-1">
|
||||
{/* Steps */}
|
||||
{task.steps?.map((step, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
{step.status === 'thinking' ? (
|
||||
<span className="w-1.5 h-1.5 mt-1.5 bg-amber-400 rounded-full animate-pulse flex-shrink-0" />
|
||||
) : step.status === 'done' ? (
|
||||
<span className="w-1.5 h-1.5 mt-1.5 bg-green-500 rounded-full flex-shrink-0" />
|
||||
) : (
|
||||
<span className="w-1.5 h-1.5 mt-1.5 bg-red-500 rounded-full flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-[11px] text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
{step.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Result */}
|
||||
{task.result && (
|
||||
<div className="text-xs text-gray-700 dark:text-gray-300 mt-1 whitespace-pre-wrap">
|
||||
{task.result}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{task.error && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
{task.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
desktop/src/components/ai/TokenMeter.tsx
Normal file
121
desktop/src/components/ai/TokenMeter.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TokenMeter — circular SVG gauge showing token usage
|
||||
// Inspired by DeerFlow's token usage display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TokenMeterProps {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
model?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Color thresholds
|
||||
function getUsageColor(percent: number): string {
|
||||
if (percent >= 80) return '#ef4444'; // red
|
||||
if (percent >= 50) return '#eab308'; // yellow
|
||||
return '#22c55e'; // green
|
||||
}
|
||||
|
||||
// Format token count for display
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export function TokenMeter({ inputTokens, outputTokens, model, className = '' }: TokenMeterProps) {
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const total = inputTokens + outputTokens;
|
||||
// Assume ~128K context window as budget for percentage calculation
|
||||
const budget = 128_000;
|
||||
const percent = Math.min(100, (total / budget) * 100);
|
||||
const color = getUsageColor(percent);
|
||||
|
||||
// SVG circular gauge parameters
|
||||
const size = 28;
|
||||
const strokeWidth = 3;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (percent / 100) * circumference;
|
||||
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<button
|
||||
onClick={() => setShowDetail(!showDetail)}
|
||||
onMouseEnter={() => setShowDetail(true)}
|
||||
onMouseLeave={() => setShowDetail(false)}
|
||||
className="focus:outline-none"
|
||||
title="Token 用量"
|
||||
>
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-gray-200 dark:text-gray-700"
|
||||
/>
|
||||
{/* Usage arc */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
{/* Center text */}
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[9px] font-medium text-gray-500 dark:text-gray-400">
|
||||
{percent >= 1 ? `${Math.round(percent)}` : '<1'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Hover detail card */}
|
||||
<AnimatePresence>
|
||||
{showDetail && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 4, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute bottom-full right-0 mb-2 w-44 p-3 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-gray-500 dark:text-gray-400">Input</span>
|
||||
<span className="text-[11px] font-medium text-gray-700 dark:text-gray-200">{formatTokens(inputTokens)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-gray-500 dark:text-gray-400">Output</span>
|
||||
<span className="text-[11px] font-medium text-gray-700 dark:text-gray-200">{formatTokens(outputTokens)}</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 pt-1.5 flex items-center justify-between">
|
||||
<span className="text-[11px] text-gray-500 dark:text-gray-400">Total</span>
|
||||
<span className="text-[11px] font-bold text-gray-800 dark:text-gray-100">{formatTokens(total)}</span>
|
||||
</div>
|
||||
{model && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 pt-1.5">
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 truncate block">{model}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
255
desktop/src/components/ai/ToolCallChain.tsx
Normal file
255
desktop/src/components/ai/ToolCallChain.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Search,
|
||||
Globe,
|
||||
Terminal,
|
||||
FileText,
|
||||
FilePlus,
|
||||
FolderOpen,
|
||||
FileEdit,
|
||||
HelpCircle,
|
||||
Code2,
|
||||
Wrench,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ToolCallStep {
|
||||
id: string;
|
||||
toolName: string;
|
||||
input?: string;
|
||||
output?: string;
|
||||
status: 'running' | 'completed' | 'error';
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface ToolCallChainProps {
|
||||
steps: ToolCallStep[];
|
||||
isStreaming?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon mapping — each tool type gets a distinctive icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOOL_ICONS: Record<string, typeof Search> = {
|
||||
web_search: Search,
|
||||
web_fetch: Globe,
|
||||
bash: Terminal,
|
||||
read_file: FileText,
|
||||
write_file: FilePlus,
|
||||
ls: FolderOpen,
|
||||
str_replace: FileEdit,
|
||||
ask_clarification: HelpCircle,
|
||||
code_execute: Code2,
|
||||
// Default fallback
|
||||
};
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
web_search: '搜索',
|
||||
web_fetch: '获取网页',
|
||||
bash: '执行命令',
|
||||
read_file: '读取文件',
|
||||
write_file: '写入文件',
|
||||
ls: '列出目录',
|
||||
str_replace: '编辑文件',
|
||||
ask_clarification: '澄清问题',
|
||||
code_execute: '执行代码',
|
||||
};
|
||||
|
||||
function getToolIcon(toolName: string): typeof Search {
|
||||
const lower = toolName.toLowerCase();
|
||||
for (const [key, icon] of Object.entries(TOOL_ICONS)) {
|
||||
if (lower.includes(key)) return icon;
|
||||
}
|
||||
return Wrench;
|
||||
}
|
||||
|
||||
function getToolLabel(toolName: string): string {
|
||||
const lower = toolName.toLowerCase();
|
||||
for (const [key, label] of Object.entries(TOOL_LABELS)) {
|
||||
if (lower.includes(key)) return label;
|
||||
}
|
||||
return toolName;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Truncate helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
if (!str) return '';
|
||||
const oneLine = str.replace(/\n/g, ' ').trim();
|
||||
return oneLine.length > maxLen ? oneLine.slice(0, maxLen) + '...' : oneLine;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolCallChain — main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Collapsible tool-call step chain.
|
||||
*
|
||||
* Inspired by DeerFlow's message-group.tsx convertToSteps():
|
||||
* - Each tool call shows a type-specific icon + label
|
||||
* - The latest 2 steps are expanded by default
|
||||
* - Earlier steps collapse into "查看其他 N 个步骤"
|
||||
* - Running steps show a spinner; completed show a checkmark
|
||||
*/
|
||||
|
||||
const DEFAULT_EXPANDED_COUNT = 2;
|
||||
|
||||
export function ToolCallChain({ steps, isStreaming = false, className = '' }: ToolCallChainProps) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
const visibleSteps = showAll
|
||||
? steps
|
||||
: steps.slice(-DEFAULT_EXPANDED_COUNT);
|
||||
|
||||
const hiddenCount = steps.length - visibleSteps.length;
|
||||
|
||||
// The last step is "active" during streaming
|
||||
const activeStepIdx = isStreaming ? steps.length - 1 : -1;
|
||||
|
||||
return (
|
||||
<div className={`my-1.5 ${className}`}>
|
||||
{/* Collapsed indicator */}
|
||||
{hiddenCount > 0 && !showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500 hover:text-orange-500 dark:hover:text-orange-400 transition-colors mb-1.5 ml-0.5 group"
|
||||
>
|
||||
<ChevronDown className="w-3 h-3 group-hover:text-orange-500 dark:group-hover:text-orange-400 transition-transform" />
|
||||
<span>查看其他 {hiddenCount} 个步骤</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Steps list */}
|
||||
<div className="space-y-0.5">
|
||||
{visibleSteps.map((step, idx) => {
|
||||
const globalIdx = showAll ? idx : hiddenCount + idx;
|
||||
const isActive = globalIdx === activeStepIdx;
|
||||
const isLast = globalIdx === steps.length - 1;
|
||||
|
||||
return (
|
||||
<ToolStepRow
|
||||
key={step.id}
|
||||
step={step}
|
||||
isActive={isActive}
|
||||
showConnector={!isLast}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolStepRow — a single step in the chain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ToolStepRowProps {
|
||||
step: ToolCallStep;
|
||||
isActive: boolean;
|
||||
showConnector: boolean;
|
||||
}
|
||||
|
||||
function ToolStepRow({ step, isActive, showConnector }: ToolStepRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const Icon = getToolIcon(step.toolName);
|
||||
const label = getToolLabel(step.toolName);
|
||||
const isRunning = step.status === 'running';
|
||||
const isError = step.status === 'error';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={`
|
||||
flex items-center gap-2 w-full text-left px-2 py-1 rounded-md transition-colors
|
||||
${isActive
|
||||
? 'bg-orange-50 dark:bg-orange-900/15'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800/60'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
{isRunning ? (
|
||||
<Loader2 className="w-3.5 h-3.5 text-orange-500 animate-spin flex-shrink-0" />
|
||||
) : isError ? (
|
||||
<XCircle className="w-3.5 h-3.5 text-red-400 flex-shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Tool icon */}
|
||||
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${isActive ? 'text-orange-500' : 'text-gray-400 dark:text-gray-500'}`} />
|
||||
|
||||
{/* Tool label */}
|
||||
<span className={`text-xs font-medium ${isActive ? 'text-orange-600 dark:text-orange-400' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{/* Input preview */}
|
||||
{step.input && !expanded && (
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate flex-1">
|
||||
{truncate(step.input, 60)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Expand chevron */}
|
||||
{(step.input || step.output) && (
|
||||
<motion.span
|
||||
animate={{ rotate: expanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="ml-auto flex-shrink-0"
|
||||
>
|
||||
<ChevronDown className="w-3 h-3 text-gray-400" />
|
||||
</motion.span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded details */}
|
||||
<AnimatePresence>
|
||||
{expanded && (step.input || step.output) && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="ml-9 mr-2 mb-1 space-y-1">
|
||||
{step.input && (
|
||||
<div className="text-[11px] text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/80 rounded px-2 py-1 font-mono overflow-x-auto">
|
||||
{truncate(step.input, 500)}
|
||||
</div>
|
||||
)}
|
||||
{step.output && (
|
||||
<div className={`text-[11px] font-mono rounded px-2 py-1 overflow-x-auto ${isError ? 'text-red-500 bg-red-50 dark:bg-red-900/10' : 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/10'}`}>
|
||||
{truncate(step.output, 500)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Vertical connector */}
|
||||
{showConnector && (
|
||||
<div className="ml-[18px] w-px h-1.5 bg-gray-200 dark:bg-gray-700" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
desktop/src/components/ai/index.ts
Normal file
11
desktop/src/components/ai/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { Conversation, ConversationProvider, useConversationContext } from './Conversation';
|
||||
export { ReasoningBlock } from './ReasoningBlock';
|
||||
export { StreamingText } from './StreamingText';
|
||||
export { ChatMode, type ChatModeType, type ChatModeConfig, CHAT_MODES } from './ChatMode';
|
||||
export { ModelSelector } from './ModelSelector';
|
||||
export { TaskProgress, type Subtask, TaskProvider, useTaskContext } from './TaskProgress';
|
||||
export { SuggestionChips } from './SuggestionChips';
|
||||
export { ResizableChatLayout } from './ResizableChatLayout';
|
||||
export { ToolCallChain, type ToolCallStep } from './ToolCallChain';
|
||||
export { ArtifactPanel, type ArtifactFile } from './ArtifactPanel';
|
||||
export { TokenMeter } from './TokenMeter';
|
||||
Reference in New Issue
Block a user