Files
DeskClaw/src/pages/Chat/index.tsx

237 lines
9.3 KiB
TypeScript

/**
* Chat Page
* Native React implementation communicating with OpenClaw Gateway
* via gateway:rpc IPC. Session selector, thinking toggle, and refresh
* are in the toolbar; messages render with markdown + streaming.
*/
import { useEffect, useRef, useState } from 'react';
import { AlertCircle, Bot, MessageSquare, Sparkles } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { useChatStore, type RawMessage } from '@/stores/chat';
import { useGatewayStore } from '@/stores/gateway';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput';
import { ChatToolbar } from './ChatToolbar';
import { extractImages, extractText, extractThinking, extractToolUse } from './message-utils';
import { useTranslation } from 'react-i18next';
export function Chat() {
const { t } = useTranslation('chat');
const gatewayStatus = useGatewayStore((s) => s.status);
const isGatewayRunning = gatewayStatus.state === 'running';
const messages = useChatStore((s) => s.messages);
const loading = useChatStore((s) => s.loading);
const sending = useChatStore((s) => s.sending);
const error = useChatStore((s) => s.error);
const showThinking = useChatStore((s) => s.showThinking);
const streamingMessage = useChatStore((s) => s.streamingMessage);
const streamingTools = useChatStore((s) => s.streamingTools);
const loadHistory = useChatStore((s) => s.loadHistory);
const loadSessions = useChatStore((s) => s.loadSessions);
const sendMessage = useChatStore((s) => s.sendMessage);
const abortRun = useChatStore((s) => s.abortRun);
const clearError = useChatStore((s) => s.clearError);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [streamingTimestamp, setStreamingTimestamp] = useState<number>(0);
// Load data when gateway is running
useEffect(() => {
if (!isGatewayRunning) return;
let cancelled = false;
(async () => {
await loadSessions();
if (cancelled) return;
await loadHistory();
})();
return () => {
cancelled = true;
};
}, [isGatewayRunning, loadHistory, loadSessions]);
// Auto-scroll on new messages or streaming
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingMessage, sending]);
// Update timestamp when sending starts
useEffect(() => {
if (sending && streamingTimestamp === 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setStreamingTimestamp(Date.now() / 1000);
} else if (!sending && streamingTimestamp !== 0) {
setStreamingTimestamp(0);
}
}, [sending, streamingTimestamp]);
// Gateway not running
if (!isGatewayRunning) {
return (
<div className="flex h-[calc(100vh-8rem)] flex-col items-center justify-center text-center p-8">
<AlertCircle className="h-12 w-12 text-yellow-500 mb-4" />
<h2 className="text-xl font-semibold mb-2">{t('gatewayNotRunning')}</h2>
<p className="text-muted-foreground max-w-md">
{t('gatewayRequired')}
</p>
</div>
);
}
// Extract streaming text for display
const streamMsg = streamingMessage && typeof streamingMessage === 'object'
? streamingMessage as unknown as { role?: string; content?: unknown; timestamp?: number }
: null;
const streamText = streamMsg ? extractText(streamMsg) : (typeof streamingMessage === 'string' ? streamingMessage : '');
const hasStreamText = streamText.trim().length > 0;
const streamThinking = streamMsg ? extractThinking(streamMsg) : null;
const hasStreamThinking = showThinking && !!streamThinking && streamThinking.trim().length > 0;
const streamTools = streamMsg ? extractToolUse(streamMsg) : [];
const hasStreamTools = showThinking && streamTools.length > 0;
const streamImages = streamMsg ? extractImages(streamMsg) : [];
const hasStreamImages = streamImages.length > 0;
const hasStreamToolStatus = showThinking && streamingTools.length > 0;
const shouldRenderStreaming = sending && (hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus);
return (
<div className="flex flex-col -m-6" style={{ height: 'calc(100vh - 2.5rem)' }}>
{/* Toolbar */}
<div className="flex shrink-0 items-center justify-end px-4 py-2">
<ChatToolbar />
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="max-w-4xl mx-auto space-y-4">
{loading ? (
<div className="flex h-full items-center justify-center py-20">
<LoadingSpinner size="lg" />
</div>
) : messages.length === 0 && !sending ? (
<WelcomeScreen />
) : (
<>
{messages.map((msg, idx) => (
<ChatMessage
key={msg.id || `msg-${idx}`}
message={msg}
showThinking={showThinking}
/>
))}
{/* Streaming message */}
{shouldRenderStreaming && (
<ChatMessage
message={(streamMsg
? {
...(streamMsg as Record<string, unknown>),
role: (typeof streamMsg.role === 'string' ? streamMsg.role : 'assistant') as RawMessage['role'],
content: streamMsg.content ?? streamText,
timestamp: streamMsg.timestamp ?? streamingTimestamp,
}
: {
role: 'assistant',
content: streamText,
timestamp: streamingTimestamp,
}) as RawMessage}
showThinking={showThinking}
isStreaming
streamingTools={streamingTools}
/>
)}
{/* Typing indicator when sending but no stream yet */}
{sending && !hasStreamText && !hasStreamThinking && !hasStreamTools && !hasStreamImages && !hasStreamToolStatus && (
<TypingIndicator />
)}
</>
)}
{/* Scroll anchor */}
<div ref={messagesEndRef} />
</div>
</div>
{/* Error bar */}
{error && (
<div className="px-4 py-2 bg-destructive/10 border-t border-destructive/20">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<p className="text-sm text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
{error}
</p>
<button
onClick={clearError}
className="text-xs text-destructive/60 hover:text-destructive underline"
>
{t('common:actions.dismiss')}
</button>
</div>
</div>
)}
{/* Input Area */}
<ChatInput
onSend={sendMessage}
onStop={abortRun}
disabled={!isGatewayRunning}
sending={sending}
/>
</div>
);
}
// ── Welcome Screen ──────────────────────────────────────────────
function WelcomeScreen() {
const { t } = useTranslation('chat');
return (
<div className="flex flex-col items-center justify-center text-center py-20">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center mb-6">
<Bot className="h-8 w-8 text-white" />
</div>
<h2 className="text-2xl font-bold mb-2">{t('welcome.title')}</h2>
<p className="text-muted-foreground mb-8 max-w-md">
{t('welcome.subtitle')}
</p>
<div className="grid grid-cols-2 gap-4 max-w-lg w-full">
{[
{ icon: MessageSquare, title: t('welcome.askQuestions'), desc: t('welcome.askQuestionsDesc') },
{ icon: Sparkles, title: t('welcome.creativeTasks'), desc: t('welcome.creativeTasksDesc') },
].map((item, i) => (
<Card key={i} className="text-left">
<CardContent className="p-4">
<item.icon className="h-6 w-6 text-primary mb-2" />
<h3 className="font-medium">{item.title}</h3>
<p className="text-sm text-muted-foreground">{item.desc}</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}
// ── Typing Indicator ────────────────────────────────────────────
function TypingIndicator() {
return (
<div className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
<Sparkles className="h-4 w-4" />
</div>
<div className="bg-muted rounded-2xl px-4 py-3">
<div className="flex gap-1">
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
);
}
export default Chat;