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:
Nicolas Varrot
2026-02-12 16:49:16 +00:00
parent a17fbf134a
commit 581675d00c
4 changed files with 61 additions and 2 deletions

View File

@@ -10,7 +10,7 @@ import { CodeBlock } from './CodeBlock';
import { ToolCall } from './ToolCall';
import { ImageBlock } from './ImageBlock';
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 { useLocale } from '../hooks/useLocale';
// ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage
@@ -244,10 +244,36 @@ function getPlainText(message: ChatMessageType): string {
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 }) {
useLocale(); // re-render on locale change
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
if (!isUser && message.blocks.length > 0) {
const textBlocks = getTextBlocks(message.blocks);

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { GatewayClient, type JsonPayload } from '../lib/gateway';
import { genIdempotencyKey } from '../lib/utils';
import { getStoredCredentials, storeCredentials, clearCredentials } from '../lib/credentials';
import { isSystemEvent } from '../lib/systemEvent';
import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types';
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 {
id: m.id || `hist-${i}`,
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(),
blocks,
isSystemEvent: role === 'user' && isSystemEvent(textContent),
};
});
const merged: ChatMessage[] = [];

29
src/lib/systemEvent.ts Normal file
View 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));
}

View File

@@ -6,6 +6,7 @@ export interface ChatMessage {
blocks: MessageBlock[];
isStreaming?: boolean;
runId?: string;
isSystemEvent?: boolean;
}
export type MessageBlock =