feat(chat): opt pic show in chat history (#87)
This commit is contained in:
@@ -120,6 +120,16 @@ async function initialize(): Promise<void> {
|
|||||||
// Create system tray
|
// Create system tray
|
||||||
createTray(mainWindow);
|
createTray(mainWindow);
|
||||||
|
|
||||||
|
// Inject OpenRouter site headers (HTTP-Referer & X-Title) for rankings on openrouter.ai
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
|
{ urls: ['https://openrouter.ai/*'] },
|
||||||
|
(details, callback) => {
|
||||||
|
details.requestHeaders['HTTP-Referer'] = 'https://claw-x.com';
|
||||||
|
details.requestHeaders['X-Title'] = 'ClawX';
|
||||||
|
callback({ requestHeaders: details.requestHeaders });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Override security headers ONLY for the OpenClaw Gateway Control UI
|
// Override security headers ONLY for the OpenClaw Gateway Control UI
|
||||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||||
const isGatewayUrl = details.url.includes('127.0.0.1:18789') || details.url.includes('localhost:18789');
|
const isGatewayUrl = details.url.includes('127.0.0.1:18789') || details.url.includes('localhost:18789');
|
||||||
|
|||||||
@@ -1505,17 +1505,22 @@ function mimeToExt(mimeType: string): string {
|
|||||||
const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
|
const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a small preview data URL for image files.
|
* Generate a preview data URL for image files.
|
||||||
* Uses Electron nativeImage to resize large images for thumbnails.
|
* Resizes large images while preserving aspect ratio (only constrain the
|
||||||
|
* longer side so the image is never squished). The frontend handles
|
||||||
|
* square cropping via CSS object-fit: cover.
|
||||||
*/
|
*/
|
||||||
function generateImagePreview(filePath: string, mimeType: string): string | null {
|
function generateImagePreview(filePath: string, mimeType: string): string | null {
|
||||||
try {
|
try {
|
||||||
const img = nativeImage.createFromPath(filePath);
|
const img = nativeImage.createFromPath(filePath);
|
||||||
if (img.isEmpty()) return null;
|
if (img.isEmpty()) return null;
|
||||||
const size = img.getSize();
|
const size = img.getSize();
|
||||||
// If image is large, resize for thumbnail
|
const maxDim = 512; // keep enough resolution for crisp display on Retina
|
||||||
if (size.width > 256 || size.height > 256) {
|
// Only resize if larger than threshold — specify ONE dimension to keep ratio
|
||||||
const resized = img.resize({ width: 256, height: 256 });
|
if (size.width > maxDim || size.height > maxDim) {
|
||||||
|
const resized = size.width >= size.height
|
||||||
|
? img.resize({ width: maxDim }) // landscape / square → constrain width
|
||||||
|
: img.resize({ height: maxDim }); // portrait → constrain height
|
||||||
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
|
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
|
||||||
}
|
}
|
||||||
// Small image — use original
|
// Small image — use original
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
// Never render tool result messages in chat UI
|
// Never render tool result messages in chat UI
|
||||||
if (isToolResult) return null;
|
if (isToolResult) return null;
|
||||||
|
|
||||||
// Don't render empty messages
|
// Don't render empty messages (also keep messages with streaming tool status)
|
||||||
if (!hasText && !visibleThinking && images.length === 0 && visibleTools.length === 0 && attachedFiles.length === 0) return null;
|
const hasStreamingToolStatus = showThinking && isStreaming && streamingTools.length > 0;
|
||||||
|
if (!hasText && !visibleThinking && images.length === 0 && visibleTools.length === 0 && attachedFiles.length === 0 && !hasStreamingToolStatus) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -95,6 +96,59 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Images — rendered ABOVE text bubble for user messages */}
|
||||||
|
{/* Images from content blocks (Gateway session data) */}
|
||||||
|
{isUser && images.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{images.map((img, i) => (
|
||||||
|
<div
|
||||||
|
key={`content-${i}`}
|
||||||
|
className="w-36 h-36 rounded-xl border overflow-hidden"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`data:${img.mimeType};base64,${img.data}`}
|
||||||
|
alt="attachment"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File attachments — images above text for user, file cards below */}
|
||||||
|
{isUser && attachedFiles.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{attachedFiles.map((file, i) => {
|
||||||
|
const isImage = file.mimeType.startsWith('image/');
|
||||||
|
// Skip image attachments if we already have images from content blocks
|
||||||
|
if (isImage && images.length > 0) return null;
|
||||||
|
// Image files → always render as square crop (with preview or placeholder)
|
||||||
|
if (isImage) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`local-${i}`}
|
||||||
|
className="w-36 h-36 rounded-xl border overflow-hidden bg-muted"
|
||||||
|
>
|
||||||
|
{file.preview ? (
|
||||||
|
<img
|
||||||
|
src={file.preview}
|
||||||
|
alt={file.fileName}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||||
|
<File className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Non-image files → file card
|
||||||
|
return <FileCard key={`local-${i}`} file={file} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main text bubble */}
|
{/* Main text bubble */}
|
||||||
{hasText && (
|
{hasText && (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
@@ -105,43 +159,54 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Images from content blocks (Gateway session data — persists across history reloads) */}
|
{/* Images from content blocks — assistant messages (below text) */}
|
||||||
{images.length > 0 && (
|
{!isUser && images.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{images.map((img, i) => (
|
{images.map((img, i) => (
|
||||||
<img
|
<img
|
||||||
key={`content-${i}`}
|
key={`content-${i}`}
|
||||||
src={`data:${img.mimeType};base64,${img.data}`}
|
src={`data:${img.mimeType};base64,${img.data}`}
|
||||||
alt="attachment"
|
alt="attachment"
|
||||||
className={cn(
|
className="max-w-xs rounded-lg border"
|
||||||
'rounded-lg border',
|
|
||||||
isUser ? 'max-w-[200px] max-h-48' : 'max-w-xs',
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File attachments (local preview — shown before history reload) */}
|
{/* File attachments — assistant messages (below text) */}
|
||||||
{/* Only show _attachedFiles images if no content-block images (avoid duplicates) */}
|
{!isUser && attachedFiles.length > 0 && (
|
||||||
{attachedFiles.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{attachedFiles.map((file, i) => {
|
{attachedFiles.map((file, i) => {
|
||||||
// Skip image attachments if we already have images from content blocks
|
const isImage = file.mimeType.startsWith('image/');
|
||||||
if (file.mimeType.startsWith('image/') && file.preview && images.length > 0) return null;
|
if (isImage && images.length > 0) return null;
|
||||||
return file.mimeType.startsWith('image/') && file.preview ? (
|
if (isImage && file.preview) {
|
||||||
|
return (
|
||||||
<img
|
<img
|
||||||
key={`local-${i}`}
|
key={`local-${i}`}
|
||||||
src={file.preview}
|
src={file.preview}
|
||||||
alt={file.fileName}
|
alt={file.fileName}
|
||||||
className="max-w-[200px] max-h-48 rounded-lg border"
|
className="max-w-xs rounded-lg border"
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<FileCard key={`local-${i}`} file={file} />
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if (isImage && !file.preview) {
|
||||||
|
return (
|
||||||
|
<div key={`local-${i}`} className="w-36 h-36 rounded-xl border overflow-hidden bg-muted flex items-center justify-center text-muted-foreground">
|
||||||
|
<File className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <FileCard key={`local-${i}`} file={file} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Hover timestamp for user messages (shown below content on hover) */}
|
||||||
|
{isUser && message.timestamp && (
|
||||||
|
<span className="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-200 select-none">
|
||||||
|
{formatTimestamp(message.timestamp)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -265,20 +330,14 @@ function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer: timestamp + copy */}
|
{/* Footer: copy button (assistant only; user timestamp is rendered outside the bubble) */}
|
||||||
<div className={cn(
|
{!isUser && (
|
||||||
'flex items-center gap-2 mt-2',
|
<div className="flex items-center justify-between mt-2">
|
||||||
isUser ? 'justify-end' : 'justify-between',
|
{timestamp ? (
|
||||||
)}>
|
<span className="text-xs text-muted-foreground">
|
||||||
{timestamp && (
|
|
||||||
<span className={cn(
|
|
||||||
'text-xs',
|
|
||||||
isUser ? 'text-primary-foreground/60' : 'text-muted-foreground',
|
|
||||||
)}>
|
|
||||||
{formatTimestamp(timestamp)}
|
{formatTimestamp(timestamp)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : <span />}
|
||||||
{!isUser && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -287,8 +346,8 @@ function MessageBubble({
|
|||||||
>
|
>
|
||||||
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,15 +140,17 @@ export function extractImages(message: RawMessage | unknown): Array<{ mimeType:
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract tool use blocks from a message.
|
* Extract tool use blocks from a message.
|
||||||
|
* Handles both Anthropic format (tool_use in content array) and
|
||||||
|
* OpenAI format (tool_calls array on the message object).
|
||||||
*/
|
*/
|
||||||
export function extractToolUse(message: RawMessage | unknown): Array<{ id: string; name: string; input: unknown }> {
|
export function extractToolUse(message: RawMessage | unknown): Array<{ id: string; name: string; input: unknown }> {
|
||||||
if (!message || typeof message !== 'object') return [];
|
if (!message || typeof message !== 'object') return [];
|
||||||
const msg = message as Record<string, unknown>;
|
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 }> = [];
|
const tools: Array<{ id: string; name: string; input: unknown }> = [];
|
||||||
|
|
||||||
|
// Path 1: Anthropic/normalized format — tool_use / toolCall blocks inside content array
|
||||||
|
const content = msg.content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
for (const block of content as ContentBlock[]) {
|
for (const block of content as ContentBlock[]) {
|
||||||
if ((block.type === 'tool_use' || block.type === 'toolCall') && block.name) {
|
if ((block.type === 'tool_use' || block.type === 'toolCall') && block.name) {
|
||||||
tools.push({
|
tools.push({
|
||||||
@@ -158,6 +160,32 @@ export function extractToolUse(message: RawMessage | unknown): Array<{ id: strin
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path 2: OpenAI format — tool_calls array on the message itself
|
||||||
|
// Real-time streaming events from OpenAI-compatible models (DeepSeek, etc.)
|
||||||
|
// use this format; the Gateway normalizes to Path 1 when storing history.
|
||||||
|
if (tools.length === 0) {
|
||||||
|
const toolCalls = msg.tool_calls ?? msg.toolCalls;
|
||||||
|
if (Array.isArray(toolCalls)) {
|
||||||
|
for (const tc of toolCalls as Array<Record<string, unknown>>) {
|
||||||
|
const fn = (tc.function ?? tc) as Record<string, unknown>;
|
||||||
|
const name = typeof fn.name === 'string' ? fn.name : '';
|
||||||
|
if (!name) continue;
|
||||||
|
let input: unknown;
|
||||||
|
try {
|
||||||
|
input = typeof fn.arguments === 'string' ? JSON.parse(fn.arguments) : fn.arguments ?? fn.input;
|
||||||
|
} catch {
|
||||||
|
input = fn.arguments;
|
||||||
|
}
|
||||||
|
tools.push({
|
||||||
|
id: typeof tc.id === 'string' ? tc.id : '',
|
||||||
|
name,
|
||||||
|
input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return tools;
|
return tools;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,10 +241,25 @@ function isToolOnlyMessage(message: RawMessage | undefined): boolean {
|
|||||||
if (!message) return false;
|
if (!message) return false;
|
||||||
if (isToolResultRole(message.role)) return true;
|
if (isToolResultRole(message.role)) return true;
|
||||||
|
|
||||||
|
const msg = message as unknown as Record<string, unknown>;
|
||||||
const content = message.content;
|
const content = message.content;
|
||||||
if (!Array.isArray(content)) return false;
|
|
||||||
|
|
||||||
let hasTool = false;
|
// Check OpenAI-format tool_calls field (real-time streaming from OpenAI-compatible models)
|
||||||
|
const toolCalls = msg.tool_calls ?? msg.toolCalls;
|
||||||
|
const hasOpenAITools = Array.isArray(toolCalls) && toolCalls.length > 0;
|
||||||
|
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
// Content is not an array — check if there's OpenAI-format tool_calls
|
||||||
|
if (hasOpenAITools) {
|
||||||
|
// Has tool calls but content might be empty/string — treat as tool-only
|
||||||
|
// if there's no meaningful text content
|
||||||
|
const textContent = typeof content === 'string' ? content.trim() : '';
|
||||||
|
return textContent.length === 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasTool = hasOpenAITools;
|
||||||
let hasText = false;
|
let hasText = false;
|
||||||
let hasNonToolContent = false;
|
let hasNonToolContent = false;
|
||||||
|
|
||||||
@@ -312,10 +327,11 @@ function parseDurationMs(value: unknown): number | undefined {
|
|||||||
function extractToolUseUpdates(message: unknown): ToolStatus[] {
|
function extractToolUseUpdates(message: unknown): ToolStatus[] {
|
||||||
if (!message || typeof message !== 'object') return [];
|
if (!message || typeof message !== 'object') return [];
|
||||||
const msg = message as Record<string, unknown>;
|
const msg = message as Record<string, unknown>;
|
||||||
const content = msg.content;
|
|
||||||
if (!Array.isArray(content)) return [];
|
|
||||||
|
|
||||||
const updates: ToolStatus[] = [];
|
const updates: ToolStatus[] = [];
|
||||||
|
|
||||||
|
// Path 1: Anthropic/normalized format — tool blocks inside content array
|
||||||
|
const content = msg.content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
for (const block of content as ContentBlock[]) {
|
for (const block of content as ContentBlock[]) {
|
||||||
if ((block.type !== 'tool_use' && block.type !== 'toolCall') || !block.name) continue;
|
if ((block.type !== 'tool_use' && block.type !== 'toolCall') || !block.name) continue;
|
||||||
updates.push({
|
updates.push({
|
||||||
@@ -326,6 +342,27 @@ function extractToolUseUpdates(message: unknown): ToolStatus[] {
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path 2: OpenAI format — tool_calls array on the message itself
|
||||||
|
if (updates.length === 0) {
|
||||||
|
const toolCalls = msg.tool_calls ?? msg.toolCalls;
|
||||||
|
if (Array.isArray(toolCalls)) {
|
||||||
|
for (const tc of toolCalls as Array<Record<string, unknown>>) {
|
||||||
|
const fn = (tc.function ?? tc) as Record<string, unknown>;
|
||||||
|
const name = typeof fn.name === 'string' ? fn.name : '';
|
||||||
|
if (!name) continue;
|
||||||
|
const id = typeof tc.id === 'string' ? tc.id : name;
|
||||||
|
updates.push({
|
||||||
|
id,
|
||||||
|
toolCallId: typeof tc.id === 'string' ? tc.id : undefined,
|
||||||
|
name,
|
||||||
|
status: 'running',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return updates;
|
return updates;
|
||||||
}
|
}
|
||||||
@@ -602,8 +639,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
// Async: load missing image previews from disk (updates in background)
|
// Async: load missing image previews from disk (updates in background)
|
||||||
loadMissingPreviews(enrichedMessages).then((updated) => {
|
loadMissingPreviews(enrichedMessages).then((updated) => {
|
||||||
if (updated) {
|
if (updated) {
|
||||||
// Trigger re-render with updated previews
|
// Create new object references so React.memo detects changes.
|
||||||
set({ messages: [...enrichedMessages] });
|
// loadMissingPreviews mutates AttachedFileMeta in place, so we
|
||||||
|
// must produce fresh message + file references for each affected msg.
|
||||||
|
set({
|
||||||
|
messages: enrichedMessages.map(msg =>
|
||||||
|
msg._attachedFiles
|
||||||
|
? { ...msg, _attachedFiles: msg._attachedFiles.map(f => ({ ...f })) }
|
||||||
|
: msg
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const { pendingFinal, lastUserMessageAt } = get();
|
const { pendingFinal, lastUserMessageAt } = get();
|
||||||
@@ -757,10 +802,26 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
// Only process events for the active run (or if no active run set)
|
// Only process events for the active run (or if no active run set)
|
||||||
if (activeRunId && runId && runId !== activeRunId) return;
|
if (activeRunId && runId && runId !== activeRunId) return;
|
||||||
|
|
||||||
switch (eventState) {
|
// Defensive: if state is missing but we have a message, try to infer state.
|
||||||
|
// This handles the case where the Gateway sends events without a state wrapper
|
||||||
|
// (e.g., protocol events where payload is the raw message).
|
||||||
|
let resolvedState = eventState;
|
||||||
|
if (!resolvedState && event.message && typeof event.message === 'object') {
|
||||||
|
const msg = event.message as Record<string, unknown>;
|
||||||
|
const stopReason = msg.stopReason ?? msg.stop_reason;
|
||||||
|
if (stopReason) {
|
||||||
|
// Message has a stopReason → it's a final message
|
||||||
|
resolvedState = 'final';
|
||||||
|
} else if (msg.role || msg.content) {
|
||||||
|
// Message has role/content but no stopReason → treat as delta (streaming)
|
||||||
|
resolvedState = 'delta';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (resolvedState) {
|
||||||
case 'delta': {
|
case 'delta': {
|
||||||
// Streaming update - store the cumulative message
|
// Streaming update - store the cumulative message
|
||||||
const updates = collectToolUpdates(event.message, eventState);
|
const updates = collectToolUpdates(event.message, resolvedState);
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
streamingMessage: (() => {
|
streamingMessage: (() => {
|
||||||
if (event.message && typeof event.message === 'object') {
|
if (event.message && typeof event.message === 'object') {
|
||||||
@@ -777,7 +838,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
// Message complete - add to history and clear streaming
|
// Message complete - add to history and clear streaming
|
||||||
const finalMsg = event.message as RawMessage | undefined;
|
const finalMsg = event.message as RawMessage | undefined;
|
||||||
if (finalMsg) {
|
if (finalMsg) {
|
||||||
const updates = collectToolUpdates(finalMsg, eventState);
|
const updates = collectToolUpdates(finalMsg, resolvedState);
|
||||||
if (isToolResultRole(finalMsg.role)) {
|
if (isToolResultRole(finalMsg.role)) {
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
@@ -867,6 +928,21 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
default: {
|
||||||
|
// Unknown or empty state — if we're currently sending and receive an event
|
||||||
|
// with a message, attempt to process it as streaming data. This handles
|
||||||
|
// edge cases where the Gateway sends events without a state field.
|
||||||
|
const { sending } = get();
|
||||||
|
if (sending && event.message && typeof event.message === 'object') {
|
||||||
|
console.warn(`[handleChatEvent] Unknown event state "${resolvedState}", treating message as streaming delta. Event keys:`, Object.keys(event));
|
||||||
|
const updates = collectToolUpdates(event.message, 'delta');
|
||||||
|
set((s) => ({
|
||||||
|
streamingMessage: event.message ?? s.streamingMessage,
|
||||||
|
streamingTools: updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
|||||||
|
|
||||||
// Some Gateway builds stream chat events via generic "agent" notifications.
|
// Some Gateway builds stream chat events via generic "agent" notifications.
|
||||||
// Normalize and forward them to the chat store.
|
// Normalize and forward them to the chat store.
|
||||||
|
// The Gateway may put event fields (state, message, etc.) either inside
|
||||||
|
// params.data or directly on params — we must handle both layouts.
|
||||||
window.electron.ipcRenderer.on('gateway:notification', (notification) => {
|
window.electron.ipcRenderer.on('gateway:notification', (notification) => {
|
||||||
const payload = notification as { method?: string; params?: Record<string, unknown> } | undefined;
|
const payload = notification as { method?: string; params?: Record<string, unknown> } | undefined;
|
||||||
if (!payload || payload.method !== 'agent' || !payload.params || typeof payload.params !== 'object') {
|
if (!payload || payload.method !== 'agent' || !payload.params || typeof payload.params !== 'object') {
|
||||||
@@ -73,11 +75,16 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
|||||||
const p = payload.params;
|
const p = payload.params;
|
||||||
const data = (p.data && typeof p.data === 'object') ? (p.data as Record<string, unknown>) : {};
|
const data = (p.data && typeof p.data === 'object') ? (p.data as Record<string, unknown>) : {};
|
||||||
const normalizedEvent: Record<string, unknown> = {
|
const normalizedEvent: Record<string, unknown> = {
|
||||||
|
// Spread data sub-object first (nested layout)
|
||||||
...data,
|
...data,
|
||||||
|
// Then override with top-level params fields (flat layout takes precedence)
|
||||||
runId: p.runId ?? data.runId,
|
runId: p.runId ?? data.runId,
|
||||||
sessionKey: p.sessionKey ?? data.sessionKey,
|
sessionKey: p.sessionKey ?? data.sessionKey,
|
||||||
stream: p.stream ?? data.stream,
|
stream: p.stream ?? data.stream,
|
||||||
seq: p.seq ?? data.seq,
|
seq: p.seq ?? data.seq,
|
||||||
|
// Critical: also pick up state and message from params (flat layout)
|
||||||
|
state: p.state ?? data.state,
|
||||||
|
message: p.message ?? data.message,
|
||||||
};
|
};
|
||||||
|
|
||||||
import('./chat')
|
import('./chat')
|
||||||
@@ -89,16 +96,35 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for chat events from the gateway and forward to chat store
|
// Listen for chat events from the gateway and forward to chat store.
|
||||||
|
// The data arrives as { message: payload } from handleProtocolEvent.
|
||||||
|
// The payload may be a full event wrapper ({ state, runId, message })
|
||||||
|
// or the raw chat message itself. We need to handle both.
|
||||||
window.electron.ipcRenderer.on('gateway:chat-message', (data) => {
|
window.electron.ipcRenderer.on('gateway:chat-message', (data) => {
|
||||||
try {
|
try {
|
||||||
// Dynamic import to avoid circular dependency
|
// Dynamic import to avoid circular dependency
|
||||||
import('./chat').then(({ useChatStore }) => {
|
import('./chat').then(({ useChatStore }) => {
|
||||||
const chatData = data as { message?: Record<string, unknown> } | Record<string, unknown>;
|
const chatData = data as Record<string, unknown>;
|
||||||
const event = ('message' in chatData && typeof chatData.message === 'object')
|
// Unwrap the { message: payload } wrapper from handleProtocolEvent
|
||||||
|
const payload = ('message' in chatData && typeof chatData.message === 'object')
|
||||||
? chatData.message as Record<string, unknown>
|
? chatData.message as Record<string, unknown>
|
||||||
: chatData as Record<string, unknown>;
|
: chatData;
|
||||||
useChatStore.getState().handleChatEvent(event);
|
|
||||||
|
// If payload has a 'state' field, it's already a proper event wrapper
|
||||||
|
if (payload.state) {
|
||||||
|
useChatStore.getState().handleChatEvent(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, payload is the raw message — wrap it as a 'final' event
|
||||||
|
// so handleChatEvent can process it (this happens when the Gateway
|
||||||
|
// sends protocol events with the message directly as payload).
|
||||||
|
const syntheticEvent: Record<string, unknown> = {
|
||||||
|
state: 'final',
|
||||||
|
message: payload,
|
||||||
|
runId: chatData.runId ?? payload.runId,
|
||||||
|
};
|
||||||
|
useChatStore.getState().handleChatEvent(syntheticEvent);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to forward chat event:', err);
|
console.warn('Failed to forward chat event:', err);
|
||||||
|
|||||||
Reference in New Issue
Block a user