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 { 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);
|
||||
|
||||
@@ -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
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[];
|
||||
isStreaming?: boolean;
|
||||
runId?: string;
|
||||
isSystemEvent?: boolean;
|
||||
}
|
||||
|
||||
export type MessageBlock =
|
||||
|
||||
Reference in New Issue
Block a user