feat: distinguish system events from user messages
System events (heartbeats, cron triggers, webhooks, channel events) now render as subtle inline notifications instead of full user bubbles. Detection based on content patterns ([EVENT], [cron:], [HEARTBEAT], etc.).
This commit is contained in:
@@ -10,7 +10,7 @@ import { CodeBlock } from './CodeBlock';
|
|||||||
import { ToolCall } from './ToolCall';
|
import { ToolCall } from './ToolCall';
|
||||||
import { ImageBlock } from './ImageBlock';
|
import { ImageBlock } from './ImageBlock';
|
||||||
import { buildImageSrc } from '../lib/image';
|
import { buildImageSrc } from '../lib/image';
|
||||||
import { Bot, User, Wrench, Copy, Check, RefreshCw } from 'lucide-react';
|
import { Bot, User, Wrench, Copy, Check, RefreshCw, Zap } from 'lucide-react';
|
||||||
import { t, getLocale } from '../lib/i18n';
|
import { t, getLocale } from '../lib/i18n';
|
||||||
import { useLocale } from '../hooks/useLocale';
|
import { useLocale } from '../hooks/useLocale';
|
||||||
// ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage
|
// ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage
|
||||||
@@ -244,10 +244,36 @@ function getPlainText(message: ChatMessageType): string {
|
|||||||
return message.content;
|
return message.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** System event displayed as a subtle inline notification */
|
||||||
|
function SystemEventMessage({ message }: { message: ChatMessageType }) {
|
||||||
|
const text = message.content || getTextBlocks(message.blocks).map(b => (b as Extract<MessageBlock, { type: 'text' }>).text).join(' ');
|
||||||
|
// Trim leading brackets like [cron:xxx] or [EVENT] for cleaner display
|
||||||
|
const display = text.replace(/^\[.*?\]\s*/, '').trim() || text.trim();
|
||||||
|
const label = text.match(/^\[([^\]]+)\]/)?.[1] || 'system';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in flex items-center justify-center gap-2 px-4 py-1.5 my-0.5">
|
||||||
|
<div className="flex items-center gap-1.5 max-w-[85%] rounded-full px-3 py-1 bg-zinc-800/30 border border-white/5">
|
||||||
|
<Zap className="h-3 w-3 text-zinc-500 shrink-0" />
|
||||||
|
<span className="text-[11px] font-medium text-zinc-500 shrink-0">{label}</span>
|
||||||
|
<span className="text-[11px] text-zinc-500 truncate">{display}</span>
|
||||||
|
{message.timestamp && (
|
||||||
|
<span className="text-[10px] text-zinc-600 shrink-0 ml-1">{formatTimestamp(message.timestamp)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatMessageComponent({ message, onRetry }: { message: ChatMessageType; onRetry?: (text: string) => void }) {
|
export function ChatMessageComponent({ message, onRetry }: { message: ChatMessageType; onRetry?: (text: string) => void }) {
|
||||||
useLocale(); // re-render on locale change
|
useLocale(); // re-render on locale change
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
|
|
||||||
|
// System events render as subtle inline notifications
|
||||||
|
if (message.isSystemEvent) {
|
||||||
|
return <SystemEventMessage message={message} />;
|
||||||
|
}
|
||||||
|
|
||||||
// Assistant message with no text content — only tool calls / thinking
|
// Assistant message with no text content — only tool calls / thinking
|
||||||
if (!isUser && message.blocks.length > 0) {
|
if (!isUser && message.blocks.length > 0) {
|
||||||
const textBlocks = getTextBlocks(message.blocks);
|
const textBlocks = getTextBlocks(message.blocks);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
|||||||
import { GatewayClient, type JsonPayload } from '../lib/gateway';
|
import { GatewayClient, type JsonPayload } from '../lib/gateway';
|
||||||
import { genIdempotencyKey } from '../lib/utils';
|
import { genIdempotencyKey } from '../lib/utils';
|
||||||
import { getStoredCredentials, storeCredentials, clearCredentials } from '../lib/credentials';
|
import { getStoredCredentials, storeCredentials, clearCredentials } from '../lib/credentials';
|
||||||
|
import { isSystemEvent } from '../lib/systemEvent';
|
||||||
import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types';
|
import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types';
|
||||||
|
|
||||||
interface ChatPayloadMessage {
|
interface ChatPayloadMessage {
|
||||||
@@ -152,12 +153,14 @@ export function useGateway() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textContent = blocks.filter((b): b is Extract<MessageBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('');
|
||||||
return {
|
return {
|
||||||
id: m.id || `hist-${i}`,
|
id: m.id || `hist-${i}`,
|
||||||
role,
|
role,
|
||||||
content: blocks.filter((b): b is Extract<MessageBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join(''),
|
content: textContent,
|
||||||
timestamp: m.timestamp || Date.now(),
|
timestamp: m.timestamp || Date.now(),
|
||||||
blocks,
|
blocks,
|
||||||
|
isSystemEvent: role === 'user' && isSystemEvent(textContent),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const merged: ChatMessage[] = [];
|
const merged: ChatMessage[] = [];
|
||||||
|
|||||||
29
src/lib/systemEvent.ts
Normal file
29
src/lib/systemEvent.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Detect whether a user-role message is actually a system event
|
||||||
|
* (heartbeat, webhook, cron, channel event, etc.) rather than
|
||||||
|
* a real human message.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SYSTEM_PATTERNS: RegExp[] = [
|
||||||
|
// Explicit markers
|
||||||
|
/^\[EVENT\b/i,
|
||||||
|
/\[from:\s*[^\]]*\(system\)\]/i,
|
||||||
|
/^\[HEARTBEAT\b/i,
|
||||||
|
/^\[cron:/i,
|
||||||
|
/^\[hook:/i,
|
||||||
|
/^\[webhook:/i,
|
||||||
|
/^\[sms-inbound\b/i,
|
||||||
|
/^\[teamspeak\b/i,
|
||||||
|
|
||||||
|
// Heartbeat prompt pattern (the standard OpenClaw heartbeat)
|
||||||
|
/^Read HEARTBEAT\.md if it exists/,
|
||||||
|
|
||||||
|
// System event envelope: [source:xxx]
|
||||||
|
/^\[source:\s*\w+\]/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isSystemEvent(text: string): boolean {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
return SYSTEM_PATTERNS.some(pat => pat.test(trimmed));
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export interface ChatMessage {
|
|||||||
blocks: MessageBlock[];
|
blocks: MessageBlock[];
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
|
isSystemEvent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageBlock =
|
export type MessageBlock =
|
||||||
|
|||||||
Reference in New Issue
Block a user