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:
85
src/pages/Chat/ChatInput.tsx
Normal file
85
src/pages/Chat/ChatInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
src/pages/Chat/ChatMessage.tsx
Normal file
249
src/pages/Chat/ChatMessage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/pages/Chat/ChatToolbar.tsx
Normal file
81
src/pages/Chat/ChatToolbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,79 +1,51 @@
|
|||||||
/**
|
/**
|
||||||
* Chat Page
|
* Chat Page
|
||||||
* Embeds OpenClaw's Control UI for chat functionality.
|
* Native React implementation communicating with OpenClaw Gateway
|
||||||
* The Control UI handles all chat protocol details (sessions, streaming, etc.)
|
* via gateway:rpc IPC. Session selector, thinking toggle, and refresh
|
||||||
* and is served by the Gateway at http://127.0.0.1:{port}/
|
* are in the toolbar; messages render with markdown + streaming.
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { AlertCircle, RefreshCw, Loader2 } from 'lucide-react';
|
import { AlertCircle, Bot, MessageSquare, Sparkles } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { useChatStore } from '@/stores/chat';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
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() {
|
export function Chat() {
|
||||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
const gatewayStatus = useGatewayStore((s) => s.status);
|
||||||
const [controlUiUrl, setControlUiUrl] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const isGatewayRunning = gatewayStatus.state === 'running';
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isGatewayRunning) {
|
if (isGatewayRunning) {
|
||||||
setControlUiUrl(null);
|
loadHistory();
|
||||||
setLoading(false);
|
loadSessions();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [isGatewayRunning, loadHistory, loadSessions]);
|
||||||
|
|
||||||
setLoading(true);
|
// Auto-scroll on new messages or streaming
|
||||||
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]);
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading || !controlUiUrl) return;
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
const timer = setTimeout(() => {
|
}, [messages, streamingMessage, sending]);
|
||||||
setLoading(false);
|
|
||||||
}, 5000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [loading, controlUiUrl]);
|
|
||||||
|
|
||||||
// Gateway not running state
|
// Gateway not running
|
||||||
if (!isGatewayRunning) {
|
if (!isGatewayRunning) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)] flex-col items-center justify-center text-center p-8">
|
<div className="flex h-[calc(100vh-8rem)] flex-col items-center justify-center text-center p-8">
|
||||||
@@ -87,46 +59,135 @@ export function Chat() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract streaming text for display
|
||||||
|
const streamText = streamingMessage ? extractText(streamingMessage) : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-4rem)] flex-col relative">
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||||
{/* Loading overlay */}
|
{/* Toolbar: session selector, refresh, thinking toggle */}
|
||||||
{loading && (
|
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80">
|
<div /> {/* spacer */}
|
||||||
<div className="flex flex-col items-center gap-3">
|
<ChatToolbar />
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Loading chat...</p>
|
|
||||||
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error state */}
|
{/* Input Area */}
|
||||||
{error && !loading && (
|
<ChatInput
|
||||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background p-8 text-center">
|
onSend={sendMessage}
|
||||||
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
|
disabled={!isGatewayRunning}
|
||||||
<h2 className="text-xl font-semibold mb-2">Connection Error</h2>
|
sending={sending}
|
||||||
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
|
/>
|
||||||
<Button onClick={handleReload} variant="outline">
|
</div>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
);
|
||||||
Retry
|
}
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Embedded Control UI via iframe */}
|
// ── Welcome Screen ──────────────────────────────────────────────
|
||||||
{controlUiUrl && (
|
|
||||||
<iframe
|
function WelcomeScreen() {
|
||||||
src={controlUiUrl}
|
return (
|
||||||
onLoad={handleIframeLoad}
|
<div className="flex flex-col items-center justify-center text-center py-20">
|
||||||
onError={handleIframeError}
|
<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">
|
||||||
className="flex-1 w-full border-0"
|
<Bot className="h-8 w-8 text-white" />
|
||||||
style={{
|
</div>
|
||||||
display: error && !loading ? 'none' : 'block',
|
<h2 className="text-2xl font-bold mb-2">ClawX Chat</h2>
|
||||||
height: '100%',
|
<p className="text-muted-foreground mb-8 max-w-md">
|
||||||
}}
|
Your AI assistant is ready. Start a conversation below.
|
||||||
title="ClawX Chat"
|
</p>
|
||||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-modals"
|
|
||||||
/>
|
<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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
131
src/pages/Chat/message-utils.ts
Normal file
131
src/pages/Chat/message-utils.ts
Normal 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' });
|
||||||
|
}
|
||||||
@@ -1,216 +1,280 @@
|
|||||||
/**
|
/**
|
||||||
* Chat State Store
|
* 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';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
/**
|
// ── Types ────────────────────────────────────────────────────────
|
||||||
* Tool call in a message
|
|
||||||
*/
|
/** Raw message from OpenClaw chat.history */
|
||||||
interface ToolCall {
|
export interface RawMessage {
|
||||||
id: string;
|
role: 'user' | 'assistant' | 'system' | 'toolresult';
|
||||||
name: string;
|
content: unknown; // string | ContentBlock[]
|
||||||
arguments: Record<string, unknown>;
|
timestamp?: number;
|
||||||
result?: unknown;
|
id?: string;
|
||||||
status: 'pending' | 'running' | 'completed' | 'error';
|
toolCallId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Content block inside a message */
|
||||||
* Chat message
|
export interface ContentBlock {
|
||||||
*/
|
type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result';
|
||||||
interface ChatMessage {
|
text?: string;
|
||||||
id: string;
|
thinking?: string;
|
||||||
role: 'user' | 'assistant' | 'system';
|
source?: { type: string; media_type: string; data: string };
|
||||||
content: string;
|
id?: string;
|
||||||
timestamp: string;
|
name?: string;
|
||||||
channel?: string;
|
input?: unknown;
|
||||||
toolCalls?: ToolCall[];
|
content?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session from sessions.list */
|
||||||
|
export interface ChatSession {
|
||||||
|
key: string;
|
||||||
|
label?: string;
|
||||||
|
displayName?: string;
|
||||||
|
thinkingLevel?: string;
|
||||||
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatState {
|
interface ChatState {
|
||||||
messages: ChatMessage[];
|
// Messages
|
||||||
|
messages: RawMessage[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
sending: boolean;
|
|
||||||
error: string | null;
|
error: string | null;
|
||||||
// Track active run for streaming
|
|
||||||
|
// Streaming
|
||||||
|
sending: boolean;
|
||||||
activeRunId: string | null;
|
activeRunId: string | null;
|
||||||
|
streamingText: string;
|
||||||
|
streamingMessage: unknown | null;
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
sessions: ChatSession[];
|
||||||
|
currentSessionKey: string;
|
||||||
|
|
||||||
|
// Thinking
|
||||||
|
showThinking: boolean;
|
||||||
|
thinkingLevel: string | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
fetchHistory: (limit?: number) => Promise<void>;
|
loadSessions: () => Promise<void>;
|
||||||
sendMessage: (content: string, channelId?: string) => Promise<void>;
|
switchSession: (key: string) => void;
|
||||||
clearHistory: () => Promise<void>;
|
loadHistory: () => Promise<void>;
|
||||||
addMessage: (message: ChatMessage) => void;
|
sendMessage: (text: string) => Promise<void>;
|
||||||
updateMessage: (messageId: string, updates: Partial<ChatMessage>) => void;
|
|
||||||
setMessages: (messages: ChatMessage[]) => void;
|
|
||||||
handleChatEvent: (event: Record<string, unknown>) => void;
|
handleChatEvent: (event: Record<string, unknown>) => void;
|
||||||
|
toggleThinking: () => void;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Store ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const useChatStore = create<ChatState>((set, get) => ({
|
export const useChatStore = create<ChatState>((set, get) => ({
|
||||||
messages: [],
|
messages: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
sending: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
activeRunId: null,
|
|
||||||
|
|
||||||
fetchHistory: async (limit = 50) => {
|
sending: false,
|
||||||
|
activeRunId: 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 });
|
set({ loading: true, error: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// OpenClaw chat.history requires: { sessionKey, limit? }
|
|
||||||
// Response format: { sessionKey, sessionId, messages, thinkingLevel, verboseLevel }
|
|
||||||
const result = await window.electron.ipcRenderer.invoke(
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
'gateway:rpc',
|
'gateway:rpc',
|
||||||
'chat.history',
|
'chat.history',
|
||||||
{ sessionKey: 'main', limit }
|
{ sessionKey: currentSessionKey, limit: 200 }
|
||||||
) as { success: boolean; result?: { messages?: unknown[] } | unknown; error?: string };
|
) as { success: boolean; result?: Record<string, unknown>; error?: string };
|
||||||
|
|
||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
const data = result.result as Record<string, unknown>;
|
const data = result.result;
|
||||||
const rawMessages = Array.isArray(data.messages) ? data.messages : [];
|
const rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
|
||||||
|
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
|
||||||
// Map OpenClaw messages to our ChatMessage format
|
set({ messages: rawMessages, thinkingLevel, loading: false });
|
||||||
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 });
|
|
||||||
} else {
|
} else {
|
||||||
// No history yet or method not available - just show empty
|
|
||||||
set({ messages: [], loading: false });
|
set({ messages: [], loading: false });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.warn('Failed to fetch chat history:', error);
|
console.warn('Failed to load chat history:', err);
|
||||||
set({ messages: [], loading: false });
|
set({ messages: [], loading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
sendMessage: async (content, _channelId) => {
|
// ── Send message ──
|
||||||
const { addMessage } = get();
|
|
||||||
|
|
||||||
// Add user message immediately
|
sendMessage: async (text: string) => {
|
||||||
const userMessage: ChatMessage = {
|
const trimmed = text.trim();
|
||||||
id: crypto.randomUUID(),
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
const { currentSessionKey } = get();
|
||||||
|
|
||||||
|
// Add user message optimistically
|
||||||
|
const userMsg: RawMessage = {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content,
|
content: trimmed,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: Date.now() / 1000,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
};
|
};
|
||||||
addMessage(userMessage);
|
set((s) => ({
|
||||||
|
messages: [...s.messages, userMsg],
|
||||||
set({ sending: true, error: null });
|
sending: true,
|
||||||
|
error: null,
|
||||||
|
streamingText: '',
|
||||||
|
streamingMessage: null,
|
||||||
|
}));
|
||||||
|
|
||||||
try {
|
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 idempotencyKey = crypto.randomUUID();
|
||||||
const result = await window.electron.ipcRenderer.invoke(
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
'gateway:rpc',
|
'gateway:rpc',
|
||||||
'chat.send',
|
'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) {
|
if (!result.success) {
|
||||||
set({ error: result.error || 'Failed to send message', sending: false });
|
set({ error: result.error || 'Failed to send message', sending: false });
|
||||||
} else {
|
} else if (result.result?.runId) {
|
||||||
// Store the active run ID - response will come via chat events
|
set({ activeRunId: result.result.runId });
|
||||||
const runId = result.result?.runId;
|
|
||||||
if (runId) {
|
|
||||||
set({ activeRunId: runId });
|
|
||||||
}
|
|
||||||
// Keep sending=true until we receive the final chat event
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
set({ error: String(error), sending: false });
|
set({ error: String(err), sending: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clearHistory: async () => {
|
// ── Handle incoming chat events from Gateway ──
|
||||||
try {
|
|
||||||
await window.electron.ipcRenderer.invoke('gateway:rpc', 'chat.clear');
|
|
||||||
set({ messages: [] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to clear history:', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
addMessage: (message) => {
|
handleChatEvent: (event: Record<string, unknown>) => {
|
||||||
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();
|
|
||||||
const runId = String(event.runId || '');
|
const runId = String(event.runId || '');
|
||||||
const state = String(event.state || '');
|
const eventState = String(event.state || '');
|
||||||
|
const { activeRunId } = get();
|
||||||
|
|
||||||
if (state === 'delta') {
|
// Only process events for the active run (or if no active run set)
|
||||||
// Streaming delta - find or create assistant message for this run
|
if (activeRunId && runId && runId !== activeRunId) return;
|
||||||
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) {
|
switch (eventState) {
|
||||||
// Append to existing message
|
case 'delta': {
|
||||||
updateMessage(`run-${runId}`, {
|
// Streaming update - store the cumulative message
|
||||||
content: existingMsg.content + content,
|
set({
|
||||||
});
|
streamingMessage: event.message ?? get().streamingMessage,
|
||||||
} else if (content) {
|
|
||||||
// Create new assistant message
|
|
||||||
addMessage({
|
|
||||||
id: `run-${runId}`,
|
|
||||||
role: 'assistant',
|
|
||||||
content,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} else if (state === 'final') {
|
case 'final': {
|
||||||
// Final message - replace or add complete response
|
// Message complete - add to history and clear streaming
|
||||||
const messageContent = event.message as Record<string, unknown> | undefined;
|
const finalMsg = event.message as RawMessage | undefined;
|
||||||
const content = String(
|
if (finalMsg) {
|
||||||
messageContent?.content
|
set((s) => ({
|
||||||
|| messageContent?.text
|
messages: [...s.messages, {
|
||||||
|| (typeof messageContent === 'string' ? messageContent : '')
|
...finalMsg,
|
||||||
);
|
role: finalMsg.role || 'assistant',
|
||||||
|
id: finalMsg.id || `run-${runId}`,
|
||||||
const existingMsg = messages.find((m) => m.id === `run-${runId}`);
|
}],
|
||||||
if (existingMsg) {
|
streamingText: '',
|
||||||
updateMessage(`run-${runId}`, { content });
|
streamingMessage: null,
|
||||||
} else if (content) {
|
sending: false,
|
||||||
addMessage({
|
activeRunId: null,
|
||||||
id: `run-${runId}`,
|
}));
|
||||||
role: 'assistant',
|
} else {
|
||||||
content,
|
// No message in final event - reload history to get complete data
|
||||||
timestamp: new Date().toISOString(),
|
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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user