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:
Haze
2026-02-06 04:49:01 +08:00
Unverified
parent bdb734120f
commit 3468d1bdf4
6 changed files with 937 additions and 266 deletions

View File

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