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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user