fix(chat): improve message handling, fix type errors and migrate changes to enhance branch (#50)
This commit is contained in:
@@ -16,25 +16,38 @@ interface ChatMessageProps {
|
||||
message: RawMessage;
|
||||
showThinking: boolean;
|
||||
isStreaming?: boolean;
|
||||
streamingTools?: Array<{
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
name: string;
|
||||
status: 'running' | 'completed' | 'error';
|
||||
durationMs?: number;
|
||||
summary?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const ChatMessage = memo(function ChatMessage({
|
||||
message,
|
||||
showThinking,
|
||||
isStreaming = false,
|
||||
streamingTools = [],
|
||||
}: ChatMessageProps) {
|
||||
const isUser = message.role === 'user';
|
||||
const isToolResult = message.role === 'toolresult';
|
||||
const role = typeof message.role === 'string' ? message.role.toLowerCase() : '';
|
||||
const isToolResult = role === 'toolresult' || role === 'tool_result';
|
||||
const text = extractText(message);
|
||||
const hasText = text.trim().length > 0;
|
||||
const thinking = extractThinking(message);
|
||||
const images = extractImages(message);
|
||||
const tools = extractToolUse(message);
|
||||
const visibleThinking = showThinking ? thinking : null;
|
||||
const visibleTools = showThinking ? tools : [];
|
||||
|
||||
// Don't render empty tool results when thinking is hidden
|
||||
if (isToolResult && !showThinking) return null;
|
||||
// Never render tool result messages in chat UI
|
||||
if (isToolResult) return null;
|
||||
|
||||
// Don't render empty messages
|
||||
if (!text && !thinking && images.length === 0 && tools.length === 0) return null;
|
||||
if (!hasText && !visibleThinking && images.length === 0 && visibleTools.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -56,23 +69,32 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn('max-w-[80%] space-y-2', isUser && 'items-end')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col w-full max-w-[80%] space-y-2',
|
||||
isUser ? 'items-end' : 'items-start',
|
||||
)}
|
||||
>
|
||||
{showThinking && isStreaming && !isUser && streamingTools.length > 0 && (
|
||||
<ToolStatusBar tools={streamingTools} />
|
||||
)}
|
||||
|
||||
{/* Thinking section */}
|
||||
{showThinking && thinking && (
|
||||
<ThinkingBlock content={thinking} />
|
||||
{visibleThinking && (
|
||||
<ThinkingBlock content={visibleThinking} />
|
||||
)}
|
||||
|
||||
{/* Tool use cards */}
|
||||
{showThinking && tools.length > 0 && (
|
||||
{visibleTools.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{tools.map((tool, i) => (
|
||||
{visibleTools.map((tool, i) => (
|
||||
<ToolCard key={tool.id || i} name={tool.name} input={tool.input} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main text bubble */}
|
||||
{text && (
|
||||
{hasText && (
|
||||
<MessageBubble
|
||||
text={text}
|
||||
isUser={isUser}
|
||||
@@ -99,6 +121,51 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
);
|
||||
});
|
||||
|
||||
function formatDuration(durationMs?: number): string | null {
|
||||
if (!durationMs || !Number.isFinite(durationMs)) return null;
|
||||
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
|
||||
return `${(durationMs / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function ToolStatusBar({
|
||||
tools,
|
||||
}: {
|
||||
tools: Array<{
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
name: string;
|
||||
status: 'running' | 'completed' | 'error';
|
||||
durationMs?: number;
|
||||
summary?: string;
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-border/50 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="space-y-1">
|
||||
{tools.map((tool) => {
|
||||
const duration = formatDuration(tool.durationMs);
|
||||
const statusLabel = tool.status === 'running' ? 'running' : (tool.status === 'error' ? 'error' : 'done');
|
||||
return (
|
||||
<div key={tool.toolCallId || tool.id || tool.name} className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px]',
|
||||
tool.status === 'error' ? 'bg-destructive/10 text-destructive' : 'bg-foreground/5 text-muted-foreground',
|
||||
)}>
|
||||
<span className="font-mono">{tool.name}</span>
|
||||
<span className="opacity-70">{statusLabel}</span>
|
||||
</span>
|
||||
{duration && <span className="text-[11px] opacity-70">{duration}</span>}
|
||||
{tool.summary && (
|
||||
<span className="truncate text-[11px]">{tool.summary}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Message Bubble ──────────────────────────────────────────────
|
||||
|
||||
function MessageBubble({
|
||||
@@ -124,6 +191,7 @@ function MessageBubble({
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-2xl px-4 py-3',
|
||||
!isUser && 'w-full',
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted',
|
||||
@@ -205,7 +273,7 @@ function ThinkingBlock({ content }: { content: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-muted/30 text-sm">
|
||||
<div className="w-full 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)}
|
||||
|
||||
@@ -38,12 +38,12 @@ export function ChatToolbar() {
|
||||
{/* Render all sessions; if currentSessionKey is not in the list, add it */}
|
||||
{!sessions.some((s) => s.key === currentSessionKey) && (
|
||||
<option value={currentSessionKey}>
|
||||
{currentSessionKey === 'main' ? 'main' : currentSessionKey}
|
||||
{currentSessionKey}
|
||||
</option>
|
||||
)}
|
||||
{sessions.map((s) => (
|
||||
<option key={s.key} value={s.key}>
|
||||
{s.displayName || s.label || s.key}
|
||||
{s.key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AlertCircle, Bot, MessageSquare, Sparkles } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { useChatStore, type RawMessage } from '@/stores/chat';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { ChatToolbar } from './ChatToolbar';
|
||||
import { extractText } from './message-utils';
|
||||
import { extractImages, extractText, extractThinking, extractToolUse } from './message-utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function Chat() {
|
||||
@@ -27,6 +27,7 @@ export function Chat() {
|
||||
const error = useChatStore((s) => s.error);
|
||||
const showThinking = useChatStore((s) => s.showThinking);
|
||||
const streamingMessage = useChatStore((s) => s.streamingMessage);
|
||||
const streamingTools = useChatStore((s) => s.streamingTools);
|
||||
const loadHistory = useChatStore((s) => s.loadHistory);
|
||||
const loadSessions = useChatStore((s) => s.loadSessions);
|
||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||
@@ -38,10 +39,16 @@ export function Chat() {
|
||||
|
||||
// Load data when gateway is running
|
||||
useEffect(() => {
|
||||
if (isGatewayRunning) {
|
||||
loadHistory();
|
||||
loadSessions();
|
||||
}
|
||||
if (!isGatewayRunning) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
await loadSessions();
|
||||
if (cancelled) return;
|
||||
await loadHistory();
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isGatewayRunning, loadHistory, loadSessions]);
|
||||
|
||||
// Auto-scroll on new messages or streaming
|
||||
@@ -78,6 +85,14 @@ export function Chat() {
|
||||
: null;
|
||||
const streamText = streamMsg ? extractText(streamMsg) : (typeof streamingMessage === 'string' ? streamingMessage : '');
|
||||
const hasStreamText = streamText.trim().length > 0;
|
||||
const streamThinking = streamMsg ? extractThinking(streamMsg) : null;
|
||||
const hasStreamThinking = showThinking && !!streamThinking && streamThinking.trim().length > 0;
|
||||
const streamTools = streamMsg ? extractToolUse(streamMsg) : [];
|
||||
const hasStreamTools = showThinking && streamTools.length > 0;
|
||||
const streamImages = streamMsg ? extractImages(streamMsg) : [];
|
||||
const hasStreamImages = streamImages.length > 0;
|
||||
const hasStreamToolStatus = showThinking && streamingTools.length > 0;
|
||||
const shouldRenderStreaming = sending && (hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col -m-6" style={{ height: 'calc(100vh - 2.5rem)' }}>
|
||||
@@ -106,20 +121,28 @@ export function Chat() {
|
||||
))}
|
||||
|
||||
{/* Streaming message */}
|
||||
{sending && hasStreamText && (
|
||||
{shouldRenderStreaming && (
|
||||
<ChatMessage
|
||||
message={{
|
||||
role: 'assistant',
|
||||
content: streamMsg?.content ?? streamText,
|
||||
timestamp: streamMsg?.timestamp ?? streamingTimestamp,
|
||||
}}
|
||||
message={(streamMsg
|
||||
? {
|
||||
...(streamMsg as Record<string, unknown>),
|
||||
role: (typeof streamMsg.role === 'string' ? streamMsg.role : 'assistant') as RawMessage['role'],
|
||||
content: streamMsg.content ?? streamText,
|
||||
timestamp: streamMsg.timestamp ?? streamingTimestamp,
|
||||
}
|
||||
: {
|
||||
role: 'assistant',
|
||||
content: streamText,
|
||||
timestamp: streamingTimestamp,
|
||||
}) as RawMessage}
|
||||
showThinking={showThinking}
|
||||
isStreaming
|
||||
streamingTools={streamingTools}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Typing indicator when sending but no stream yet */}
|
||||
{sending && !hasStreamText && (
|
||||
{sending && !hasStreamText && !hasStreamThinking && !hasStreamTools && !hasStreamImages && !hasStreamToolStatus && (
|
||||
<TypingIndicator />
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -15,26 +15,25 @@ export function extractText(message: RawMessage | unknown): string {
|
||||
const content = msg.content;
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
return content.trim().length > 0 ? 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);
|
||||
if (block.text.trim().length > 0) {
|
||||
parts.push(block.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
const combined = parts.join('\n\n');
|
||||
return combined.trim().length > 0 ? combined : '';
|
||||
}
|
||||
|
||||
// Fallback: try .text field
|
||||
if (typeof msg.text === 'string') {
|
||||
return msg.text;
|
||||
return msg.text.trim().length > 0 ? msg.text : '';
|
||||
}
|
||||
|
||||
return '';
|
||||
@@ -54,11 +53,15 @@ export function extractThinking(message: RawMessage | unknown): string | null {
|
||||
const parts: string[] = [];
|
||||
for (const block of content as ContentBlock[]) {
|
||||
if (block.type === 'thinking' && block.thinking) {
|
||||
parts.push(block.thinking);
|
||||
const cleaned = block.thinking.trim();
|
||||
if (cleaned) {
|
||||
parts.push(cleaned);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join('\n\n') : null;
|
||||
const combined = parts.join('\n\n').trim();
|
||||
return combined.length > 0 ? combined : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,11 +100,11 @@ export function extractToolUse(message: RawMessage | unknown): Array<{ id: strin
|
||||
|
||||
const tools: Array<{ id: string; name: string; input: unknown }> = [];
|
||||
for (const block of content as ContentBlock[]) {
|
||||
if (block.type === 'tool_use' && block.name) {
|
||||
if ((block.type === 'tool_use' || block.type === 'toolCall') && block.name) {
|
||||
tools.push({
|
||||
id: block.id || '',
|
||||
name: block.name,
|
||||
input: block.input,
|
||||
input: block.input ?? block.arguments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user