feat(chat): native React chat page with session selector and streaming
Replace the iframe-based Control UI embed with a native React implementation that communicates directly with the Gateway via gateway:rpc IPC calls and chat event streaming. New components: - ChatToolbar: session selector dropdown, refresh button, thinking toggle - ChatMessage: message bubbles with markdown (react-markdown + GFM), collapsible thinking blocks, tool use cards, image attachments - ChatInput: textarea with Enter to send, Shift+Enter for new line - message-utils: extractText/extractThinking/extractImages/extractToolUse ported from OpenClaw's message-extract.ts Rewritten chat store with: - Session management (sessions.list, switchSession) - Proper chat.history loading with raw message preservation - chat.send with idempotencyKey and run tracking - Streaming via handleChatEvent (delta/final/error/aborted) - Thinking toggle (show/hide reasoning blocks)
This commit is contained in:
@@ -1,132 +1,193 @@
|
||||
/**
|
||||
* Chat Page
|
||||
* Embeds OpenClaw's Control UI for chat functionality.
|
||||
* The Control UI handles all chat protocol details (sessions, streaming, etc.)
|
||||
* and is served by the Gateway at http://127.0.0.1:{port}/
|
||||
* 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 { useState, useEffect, useCallback } from 'react';
|
||||
import { AlertCircle, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { AlertCircle, Bot, MessageSquare, Sparkles } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { ChatToolbar } from './ChatToolbar';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { extractText } from './message-utils';
|
||||
|
||||
export function Chat() {
|
||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||
const [controlUiUrl, setControlUiUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const gatewayStatus = useGatewayStore((s) => s.status);
|
||||
const isGatewayRunning = gatewayStatus.state === 'running';
|
||||
|
||||
// Fetch Control UI URL when gateway is 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 loadHistory = useChatStore((s) => s.loadHistory);
|
||||
const loadSessions = useChatStore((s) => s.loadSessions);
|
||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||
const clearError = useChatStore((s) => s.clearError);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load data when gateway is running
|
||||
useEffect(() => {
|
||||
if (!isGatewayRunning) {
|
||||
setControlUiUrl(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
if (isGatewayRunning) {
|
||||
loadHistory();
|
||||
loadSessions();
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
window.electron.ipcRenderer.invoke('gateway:getControlUiUrl')
|
||||
.then((result: unknown) => {
|
||||
const r = result as { success: boolean; url?: string; error?: string };
|
||||
if (r.success && r.url) {
|
||||
setControlUiUrl(r.url);
|
||||
} else {
|
||||
setError(r.error || 'Failed to get Control UI URL');
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(String(err));
|
||||
setLoading(false);
|
||||
});
|
||||
}, [isGatewayRunning]);
|
||||
}, [isGatewayRunning, loadHistory, loadSessions]);
|
||||
|
||||
// Handle iframe load events
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleIframeError = useCallback(() => {
|
||||
setError('Failed to load chat interface');
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Force re-mount the iframe by clearing and resetting URL
|
||||
const url = controlUiUrl;
|
||||
setControlUiUrl(null);
|
||||
setTimeout(() => setControlUiUrl(url), 100);
|
||||
}, [controlUiUrl]);
|
||||
|
||||
// Auto-hide loading after a timeout (fallback)
|
||||
// Auto-scroll on new messages or streaming
|
||||
useEffect(() => {
|
||||
if (!loading || !controlUiUrl) return;
|
||||
const timer = setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [loading, controlUiUrl]);
|
||||
|
||||
// Gateway not running state
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, streamingMessage, sending]);
|
||||
|
||||
// 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">Gateway Not Running</h2>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
The OpenClaw Gateway needs to be running to use chat.
|
||||
The OpenClaw Gateway needs to be running to use chat.
|
||||
It will start automatically, or you can start it from Settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Extract streaming text for display
|
||||
const streamText = streamingMessage ? extractText(streamingMessage) : '';
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col relative">
|
||||
{/* Loading overlay */}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Loading chat...</p>
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
{/* Toolbar: session selector, refresh, thinking toggle */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
<div /> {/* spacer */}
|
||||
<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 */}
|
||||
{sending && streamText && (
|
||||
<ChatMessage
|
||||
message={{
|
||||
role: 'assistant',
|
||||
content: streamingMessage as unknown as string,
|
||||
timestamp: Date.now() / 1000,
|
||||
}}
|
||||
showThinking={showThinking}
|
||||
isStreaming
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Typing indicator when sending but no stream yet */}
|
||||
{sending && !streamText && (
|
||||
<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"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && !loading && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background p-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Connection Error</h2>
|
||||
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
|
||||
<Button onClick={handleReload} variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
|
||||
{/* Input Area */}
|
||||
<ChatInput
|
||||
onSend={sendMessage}
|
||||
disabled={!isGatewayRunning}
|
||||
sending={sending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Welcome Screen ──────────────────────────────────────────────
|
||||
|
||||
function WelcomeScreen() {
|
||||
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">ClawX Chat</h2>
|
||||
<p className="text-muted-foreground mb-8 max-w-md">
|
||||
Your AI assistant is ready. Start a conversation below.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 max-w-lg w-full">
|
||||
{[
|
||||
{ icon: MessageSquare, title: 'Ask Questions', desc: 'Get answers on any topic' },
|
||||
{ icon: Sparkles, title: 'Creative Tasks', desc: 'Writing, brainstorming, ideas' },
|
||||
].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>
|
||||
)}
|
||||
|
||||
{/* Embedded Control UI via iframe */}
|
||||
{controlUiUrl && (
|
||||
<iframe
|
||||
src={controlUiUrl}
|
||||
onLoad={handleIframeLoad}
|
||||
onError={handleIframeError}
|
||||
className="flex-1 w-full border-0"
|
||||
style={{
|
||||
display: error && !loading ? 'none' : 'block',
|
||||
height: '100%',
|
||||
}}
|
||||
title="ClawX Chat"
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-modals"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user