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

@@ -0,0 +1,85 @@
/**
* Chat Input Component
* Textarea with send button. Enter to send, Shift+Enter for new line.
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { Send, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
interface ChatInputProps {
onSend: (text: string) => void;
disabled?: boolean;
sending?: boolean;
}
export function ChatInput({ onSend, disabled = false, sending = false }: ChatInputProps) {
const [input, setInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
}
}, [input]);
const handleSend = useCallback(() => {
const trimmed = input.trim();
if (!trimmed || disabled || sending) return;
onSend(trimmed);
setInput('');
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}, [input, disabled, sending, onSend]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
return (
<div className="border-t bg-background p-4">
<div className="flex items-end gap-2 max-w-4xl mx-auto">
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={disabled ? 'Gateway not connected...' : 'Message (Enter to send, Shift+Enter for new line)'}
disabled={disabled}
className="min-h-[44px] max-h-[200px] resize-none pr-4"
rows={1}
/>
</div>
<Button
onClick={handleSend}
disabled={!input.trim() || disabled}
size="icon"
className="shrink-0 h-10 w-10"
variant={sending ? 'destructive' : 'default'}
>
{sending ? (
<Square className="h-4 w-4" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1.5 text-center">
Enter to send, Shift+Enter for new line
</p>
</div>
);
}

View File

@@ -0,0 +1,249 @@
/**
* Chat Message Component
* Renders user / assistant / system / toolresult messages
* with markdown, thinking sections, images, and tool cards.
*/
import { useState, useCallback, memo } from 'react';
import { User, Sparkles, Copy, Check, ChevronDown, ChevronRight, Wrench } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { RawMessage } from '@/stores/chat';
import { extractText, extractThinking, extractImages, extractToolUse, formatTimestamp } from './message-utils';
interface ChatMessageProps {
message: RawMessage;
showThinking: boolean;
isStreaming?: boolean;
}
export const ChatMessage = memo(function ChatMessage({
message,
showThinking,
isStreaming = false,
}: ChatMessageProps) {
const isUser = message.role === 'user';
const isToolResult = message.role === 'toolresult';
const text = extractText(message);
const thinking = extractThinking(message);
const images = extractImages(message);
const tools = extractToolUse(message);
// Don't render empty tool results when thinking is hidden
if (isToolResult && !showThinking) return null;
// Don't render empty messages
if (!text && !thinking && images.length === 0 && tools.length === 0) return null;
return (
<div
className={cn(
'flex gap-3 group',
isUser ? 'flex-row-reverse' : 'flex-row',
)}
>
{/* Avatar */}
<div
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full mt-1',
isUser
? 'bg-primary text-primary-foreground'
: 'bg-gradient-to-br from-indigo-500 to-purple-600 text-white',
)}
>
{isUser ? <User className="h-4 w-4" /> : <Sparkles className="h-4 w-4" />}
</div>
{/* Content */}
<div className={cn('max-w-[80%] space-y-2', isUser && 'items-end')}>
{/* Thinking section */}
{showThinking && thinking && (
<ThinkingBlock content={thinking} />
)}
{/* Tool use cards */}
{showThinking && tools.length > 0 && (
<div className="space-y-1">
{tools.map((tool, i) => (
<ToolCard key={tool.id || i} name={tool.name} input={tool.input} />
))}
</div>
)}
{/* Main text bubble */}
{text && (
<MessageBubble
text={text}
isUser={isUser}
isStreaming={isStreaming}
timestamp={message.timestamp}
/>
)}
{/* Images */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2">
{images.map((img, i) => (
<img
key={i}
src={`data:${img.mimeType};base64,${img.data}`}
alt="attachment"
className="max-w-xs rounded-lg border"
/>
))}
</div>
)}
</div>
</div>
);
});
// ── Message Bubble ──────────────────────────────────────────────
function MessageBubble({
text,
isUser,
isStreaming,
timestamp,
}: {
text: string;
isUser: boolean;
isStreaming: boolean;
timestamp?: number;
}) {
const [copied, setCopied] = useState(false);
const copyContent = useCallback(() => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [text]);
return (
<div
className={cn(
'relative rounded-2xl px-4 py-3',
isUser
? 'bg-primary text-primary-foreground'
: 'bg-muted',
)}
>
{isUser ? (
<p className="whitespace-pre-wrap text-sm">{text}</p>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const isInline = !match && !className;
if (isInline) {
return (
<code className="bg-background/50 px-1.5 py-0.5 rounded text-sm font-mono" {...props}>
{children}
</code>
);
}
return (
<pre className="bg-background/50 rounded-lg p-4 overflow-x-auto">
<code className={cn('text-sm font-mono', className)} {...props}>
{children}
</code>
</pre>
);
},
a({ href, children }) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{children}
</a>
);
},
}}
>
{text}
</ReactMarkdown>
{isStreaming && (
<span className="inline-block w-2 h-4 bg-foreground/50 animate-pulse ml-0.5" />
)}
</div>
)}
{/* Footer: timestamp + copy */}
<div className={cn(
'flex items-center gap-2 mt-2',
isUser ? 'justify-end' : 'justify-between',
)}>
{timestamp && (
<span className={cn(
'text-xs',
isUser ? 'text-primary-foreground/60' : 'text-muted-foreground',
)}>
{formatTimestamp(timestamp)}
</span>
)}
{!isUser && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={copyContent}
>
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
</Button>
)}
</div>
</div>
);
}
// ── Thinking Block ──────────────────────────────────────────────
function ThinkingBlock({ content }: { content: string }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-lg border border-border/50 bg-muted/30 text-sm">
<button
className="flex items-center gap-2 w-full px-3 py-2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<span className="font-medium">Thinking</span>
</button>
{expanded && (
<div className="px-3 pb-3 text-muted-foreground">
<div className="prose prose-sm dark:prose-invert max-w-none opacity-75">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</div>
)}
</div>
);
}
// ── Tool Card ───────────────────────────────────────────────────
function ToolCard({ name, input }: { name: string; input: unknown }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-lg border border-border/50 bg-muted/20 text-sm">
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setExpanded(!expanded)}
>
<Wrench className="h-3.5 w-3.5" />
<span className="font-mono text-xs">{name}</span>
{expanded ? <ChevronDown className="h-3 w-3 ml-auto" /> : <ChevronRight className="h-3 w-3 ml-auto" />}
</button>
{expanded && input != null && (
<pre className="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto">
{typeof input === 'string' ? input : JSON.stringify(input, null, 2) as string}
</pre>
)}
</div>
);
}

View File

@@ -0,0 +1,81 @@
/**
* Chat Toolbar
* Session selector, refresh, and thinking toggle controls.
*/
import { RefreshCw, Brain, ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useChatStore } from '@/stores/chat';
import { cn } from '@/lib/utils';
export function ChatToolbar() {
const sessions = useChatStore((s) => s.sessions);
const currentSessionKey = useChatStore((s) => s.currentSessionKey);
const switchSession = useChatStore((s) => s.switchSession);
const refresh = useChatStore((s) => s.refresh);
const loading = useChatStore((s) => s.loading);
const showThinking = useChatStore((s) => s.showThinking);
const toggleThinking = useChatStore((s) => s.toggleThinking);
const handleSessionChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
switchSession(e.target.value);
};
return (
<div className="flex items-center gap-2">
{/* Session Selector */}
<div className="relative">
<select
value={currentSessionKey}
onChange={handleSessionChange}
className={cn(
'appearance-none rounded-md border border-border bg-background px-3 py-1.5 pr-8',
'text-sm text-foreground cursor-pointer',
'focus:outline-none focus:ring-2 focus:ring-ring',
)}
>
{/* Always show current session */}
<option value={currentSessionKey}>
{sessions.find((s) => s.key === currentSessionKey)?.displayName
|| sessions.find((s) => s.key === currentSessionKey)?.label
|| currentSessionKey}
</option>
{/* Other sessions */}
{sessions
.filter((s) => s.key !== currentSessionKey)
.map((s) => (
<option key={s.key} value={s.key}>
{s.displayName || s.label || s.key}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
</div>
{/* Refresh */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => refresh()}
disabled={loading}
title="Refresh chat"
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
</Button>
{/* Thinking Toggle */}
<Button
variant="ghost"
size="icon"
className={cn(
'h-8 w-8',
showThinking && 'bg-primary/10 text-primary',
)}
onClick={toggleThinking}
title={showThinking ? 'Hide thinking' : 'Show thinking'}
>
<Brain className="h-4 w-4" />
</Button>
</div>
);
}

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

View File

@@ -0,0 +1,131 @@
/**
* Message content extraction helpers
* Ported from OpenClaw's message-extract.ts to handle the various
* message content formats returned by the Gateway.
*/
import type { RawMessage, ContentBlock } from '@/stores/chat';
/**
* Extract displayable text from a message's content field.
* Handles both string content and array-of-blocks content.
*/
export function extractText(message: RawMessage | unknown): string {
if (!message || typeof message !== 'object') return '';
const msg = message as Record<string, unknown>;
const content = msg.content;
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
const parts: string[] = [];
for (const block of content as ContentBlock[]) {
if (block.type === 'text' && block.text) {
parts.push(block.text);
}
// tool_result blocks may have nested text
if (block.type === 'tool_result' && typeof block.content === 'string') {
parts.push(block.content);
}
}
return parts.join('\n\n');
}
// Fallback: try .text field
if (typeof msg.text === 'string') {
return msg.text;
}
return '';
}
/**
* Extract thinking/reasoning content from a message.
* Returns null if no thinking content found.
*/
export function extractThinking(message: RawMessage | unknown): string | null {
if (!message || typeof message !== 'object') return null;
const msg = message as Record<string, unknown>;
const content = msg.content;
if (!Array.isArray(content)) return null;
const parts: string[] = [];
for (const block of content as ContentBlock[]) {
if (block.type === 'thinking' && block.thinking) {
parts.push(block.thinking);
}
}
return parts.length > 0 ? parts.join('\n\n') : null;
}
/**
* Extract image attachments from a message.
* Returns array of { mimeType, data } for base64 images.
*/
export function extractImages(message: RawMessage | unknown): Array<{ mimeType: string; data: string }> {
if (!message || typeof message !== 'object') return [];
const msg = message as Record<string, unknown>;
const content = msg.content;
if (!Array.isArray(content)) return [];
const images: Array<{ mimeType: string; data: string }> = [];
for (const block of content as ContentBlock[]) {
if (block.type === 'image' && block.source) {
const src = block.source;
if (src.type === 'base64' && src.media_type && src.data) {
images.push({ mimeType: src.media_type, data: src.data });
}
}
}
return images;
}
/**
* Extract tool use blocks from a message.
*/
export function extractToolUse(message: RawMessage | unknown): Array<{ id: string; name: string; input: unknown }> {
if (!message || typeof message !== 'object') return [];
const msg = message as Record<string, unknown>;
const content = msg.content;
if (!Array.isArray(content)) return [];
const tools: Array<{ id: string; name: string; input: unknown }> = [];
for (const block of content as ContentBlock[]) {
if (block.type === 'tool_use' && block.name) {
tools.push({
id: block.id || '',
name: block.name,
input: block.input,
});
}
}
return tools;
}
/**
* Format a Unix timestamp (seconds) to relative time string.
*/
export function formatTimestamp(timestamp: unknown): string {
if (!timestamp) return '';
const ts = typeof timestamp === 'number' ? timestamp : Number(timestamp);
if (!ts || isNaN(ts)) return '';
// OpenClaw timestamps can be in seconds or milliseconds
const ms = ts > 1e12 ? ts : ts * 1000;
const date = new Date(ms);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
if (diffMs < 60000) return 'just now';
if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m ago`;
if (diffMs < 86400000) return `${Math.floor(diffMs / 3600000)}h ago`;
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

View File

@@ -1,216 +1,280 @@
/**
* Chat State Store
* Manages chat messages and conversation state
* Manages chat messages, sessions, streaming, and thinking state.
* Communicates with OpenClaw Gateway via gateway:rpc IPC.
*/
import { create } from 'zustand';
/**
* Tool call in a message
*/
interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
result?: unknown;
status: 'pending' | 'running' | 'completed' | 'error';
// ── Types ────────────────────────────────────────────────────────
/** Raw message from OpenClaw chat.history */
export interface RawMessage {
role: 'user' | 'assistant' | 'system' | 'toolresult';
content: unknown; // string | ContentBlock[]
timestamp?: number;
id?: string;
toolCallId?: string;
}
/**
* Chat message
*/
interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: string;
channel?: string;
toolCalls?: ToolCall[];
/** Content block inside a message */
export interface ContentBlock {
type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result';
text?: string;
thinking?: string;
source?: { type: string; media_type: string; data: string };
id?: string;
name?: string;
input?: unknown;
content?: unknown;
}
/** Session from sessions.list */
export interface ChatSession {
key: string;
label?: string;
displayName?: string;
thinkingLevel?: string;
model?: string;
}
interface ChatState {
messages: ChatMessage[];
// Messages
messages: RawMessage[];
loading: boolean;
sending: boolean;
error: string | null;
// Track active run for streaming
// Streaming
sending: boolean;
activeRunId: string | null;
streamingText: string;
streamingMessage: unknown | null;
// Sessions
sessions: ChatSession[];
currentSessionKey: string;
// Thinking
showThinking: boolean;
thinkingLevel: string | null;
// Actions
fetchHistory: (limit?: number) => Promise<void>;
sendMessage: (content: string, channelId?: string) => Promise<void>;
clearHistory: () => Promise<void>;
addMessage: (message: ChatMessage) => void;
updateMessage: (messageId: string, updates: Partial<ChatMessage>) => void;
setMessages: (messages: ChatMessage[]) => void;
loadSessions: () => Promise<void>;
switchSession: (key: string) => void;
loadHistory: () => Promise<void>;
sendMessage: (text: string) => Promise<void>;
handleChatEvent: (event: Record<string, unknown>) => void;
toggleThinking: () => void;
refresh: () => Promise<void>;
clearError: () => void;
}
// ── Store ────────────────────────────────────────────────────────
export const useChatStore = create<ChatState>((set, get) => ({
messages: [],
loading: false,
sending: false,
error: null,
sending: false,
activeRunId: null,
fetchHistory: async (limit = 50) => {
set({ loading: true, error: null });
streamingText: '',
streamingMessage: null,
sessions: [],
currentSessionKey: 'main',
showThinking: true,
thinkingLevel: null,
// ── Load sessions via sessions.list ──
loadSessions: async () => {
try {
const result = await window.electron.ipcRenderer.invoke(
'gateway:rpc',
'sessions.list',
{ limit: 50 }
) as { success: boolean; result?: Record<string, unknown>; error?: string };
if (result.success && result.result) {
const data = result.result;
const rawSessions = Array.isArray(data.sessions) ? data.sessions : [];
const sessions: ChatSession[] = rawSessions.map((s: Record<string, unknown>) => ({
key: String(s.key || ''),
label: s.label ? String(s.label) : undefined,
displayName: s.displayName ? String(s.displayName) : undefined,
thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined,
model: s.model ? String(s.model) : undefined,
})).filter((s: ChatSession) => s.key);
set({ sessions });
}
} catch (err) {
console.warn('Failed to load sessions:', err);
}
},
// ── Switch session ──
switchSession: (key: string) => {
set({
currentSessionKey: key,
messages: [],
streamingText: '',
streamingMessage: null,
activeRunId: null,
error: null,
});
// Load history for new session
get().loadHistory();
},
// ── Load chat history ──
loadHistory: async () => {
const { currentSessionKey } = get();
set({ loading: true, error: null });
try {
// OpenClaw chat.history requires: { sessionKey, limit? }
// Response format: { sessionKey, sessionId, messages, thinkingLevel, verboseLevel }
const result = await window.electron.ipcRenderer.invoke(
'gateway:rpc',
'chat.history',
{ sessionKey: 'main', limit }
) as { success: boolean; result?: { messages?: unknown[] } | unknown; error?: string };
{ sessionKey: currentSessionKey, limit: 200 }
) as { success: boolean; result?: Record<string, unknown>; error?: string };
if (result.success && result.result) {
const data = result.result as Record<string, unknown>;
const rawMessages = Array.isArray(data.messages) ? data.messages : [];
// Map OpenClaw messages to our ChatMessage format
const messages: ChatMessage[] = rawMessages.map((msg: unknown, idx: number) => {
const m = msg as Record<string, unknown>;
return {
id: String(m.id || `msg-${idx}`),
role: (m.role as 'user' | 'assistant' | 'system') || 'assistant',
content: String(m.content || m.text || ''),
timestamp: String(m.timestamp || new Date().toISOString()),
};
});
set({ messages, loading: false });
const data = result.result;
const rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
set({ messages: rawMessages, thinkingLevel, loading: false });
} else {
// No history yet or method not available - just show empty
set({ messages: [], loading: false });
}
} catch (error) {
console.warn('Failed to fetch chat history:', error);
} catch (err) {
console.warn('Failed to load chat history:', err);
set({ messages: [], loading: false });
}
},
sendMessage: async (content, _channelId) => {
const { addMessage } = get();
// Add user message immediately
const userMessage: ChatMessage = {
id: crypto.randomUUID(),
// ── Send message ──
sendMessage: async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
const { currentSessionKey } = get();
// Add user message optimistically
const userMsg: RawMessage = {
role: 'user',
content,
timestamp: new Date().toISOString(),
content: trimmed,
timestamp: Date.now() / 1000,
id: crypto.randomUUID(),
};
addMessage(userMessage);
set({ sending: true, error: null });
set((s) => ({
messages: [...s.messages, userMsg],
sending: true,
error: null,
streamingText: '',
streamingMessage: null,
}));
try {
// OpenClaw chat.send requires: { sessionKey, message, idempotencyKey }
// Response is an acknowledgment: { runId, status: "started" }
// The actual AI response comes via WebSocket chat events
const idempotencyKey = crypto.randomUUID();
const result = await window.electron.ipcRenderer.invoke(
'gateway:rpc',
'chat.send',
{ sessionKey: 'main', message: content, idempotencyKey }
) as { success: boolean; result?: { runId?: string; status?: string }; error?: string };
{
sessionKey: currentSessionKey,
message: trimmed,
deliver: false,
idempotencyKey,
}
) as { success: boolean; result?: { runId?: string }; error?: string };
if (!result.success) {
set({ error: result.error || 'Failed to send message', sending: false });
} else {
// Store the active run ID - response will come via chat events
const runId = result.result?.runId;
if (runId) {
set({ activeRunId: runId });
}
// Keep sending=true until we receive the final chat event
} else if (result.result?.runId) {
set({ activeRunId: result.result.runId });
}
} catch (error) {
set({ error: String(error), sending: false });
} catch (err) {
set({ error: String(err), sending: false });
}
},
clearHistory: async () => {
try {
await window.electron.ipcRenderer.invoke('gateway:rpc', 'chat.clear');
set({ messages: [] });
} catch (error) {
console.error('Failed to clear history:', error);
}
},
addMessage: (message) => {
set((state) => ({
messages: [...state.messages, message],
}));
},
updateMessage: (messageId, updates) => {
set((state) => ({
messages: state.messages.map((msg) =>
msg.id === messageId ? { ...msg, ...updates } : msg
),
}));
},
setMessages: (messages) => set({ messages }),
/**
* Handle incoming chat event from Gateway WebSocket
* Events: { runId, sessionKey, seq, state, message, errorMessage }
* States: "delta" (streaming), "final" (complete), "aborted", "error"
*/
handleChatEvent: (event) => {
const { addMessage, updateMessage, messages } = get();
// ── Handle incoming chat events from Gateway ──
handleChatEvent: (event: Record<string, unknown>) => {
const runId = String(event.runId || '');
const state = String(event.state || '');
if (state === 'delta') {
// Streaming delta - find or create assistant message for this run
const existingMsg = messages.find((m) => m.id === `run-${runId}`);
const messageContent = event.message as Record<string, unknown> | undefined;
const content = String(messageContent?.content || messageContent?.text || '');
if (existingMsg) {
// Append to existing message
updateMessage(`run-${runId}`, {
content: existingMsg.content + content,
});
} else if (content) {
// Create new assistant message
addMessage({
id: `run-${runId}`,
role: 'assistant',
content,
timestamp: new Date().toISOString(),
const eventState = String(event.state || '');
const { activeRunId } = get();
// Only process events for the active run (or if no active run set)
if (activeRunId && runId && runId !== activeRunId) return;
switch (eventState) {
case 'delta': {
// Streaming update - store the cumulative message
set({
streamingMessage: event.message ?? get().streamingMessage,
});
break;
}
} else if (state === 'final') {
// Final message - replace or add complete response
const messageContent = event.message as Record<string, unknown> | undefined;
const content = String(
messageContent?.content
|| messageContent?.text
|| (typeof messageContent === 'string' ? messageContent : '')
);
const existingMsg = messages.find((m) => m.id === `run-${runId}`);
if (existingMsg) {
updateMessage(`run-${runId}`, { content });
} else if (content) {
addMessage({
id: `run-${runId}`,
role: 'assistant',
content,
timestamp: new Date().toISOString(),
});
case 'final': {
// Message complete - add to history and clear streaming
const finalMsg = event.message as RawMessage | undefined;
if (finalMsg) {
set((s) => ({
messages: [...s.messages, {
...finalMsg,
role: finalMsg.role || 'assistant',
id: finalMsg.id || `run-${runId}`,
}],
streamingText: '',
streamingMessage: null,
sending: false,
activeRunId: null,
}));
} else {
// No message in final event - reload history to get complete data
set({ streamingText: '', streamingMessage: null, sending: false, activeRunId: null });
get().loadHistory();
}
break;
}
case 'error': {
const errorMsg = String(event.errorMessage || 'An error occurred');
set({
error: errorMsg,
sending: false,
activeRunId: null,
streamingText: '',
streamingMessage: null,
});
break;
}
case 'aborted': {
set({
sending: false,
activeRunId: null,
streamingText: '',
streamingMessage: null,
});
break;
}
set({ sending: false, activeRunId: null });
} else if (state === 'error') {
const errorMsg = String(event.errorMessage || 'An error occurred');
set({ error: errorMsg, sending: false, activeRunId: null });
} else if (state === 'aborted') {
set({ sending: false, activeRunId: null });
}
},
// ── Toggle thinking visibility ──
toggleThinking: () => set((s) => ({ showThinking: !s.showThinking })),
// ── Refresh: reload history + sessions ──
refresh: async () => {
const { loadHistory, loadSessions } = get();
await Promise.all([loadHistory(), loadSessions()]);
},
clearError: () => set({ error: null }),
}));