feat: prepare Zhinian desktop client for pilot release

This commit is contained in:
inman
2026-04-29 10:23:20 +08:00
parent f9361e686a
commit 47b83b79fc
149 changed files with 15341 additions and 3590 deletions

View File

@@ -6,17 +6,14 @@
* Files are staged to disk via IPC — only lightweight path references
* are sent with the message (no base64 over WebSocket).
*/
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2, AtSign } from 'lucide-react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { Check, ChevronDown, SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2, BookOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { hostApiFetch } from '@/lib/host-api';
import { invokeIpc } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { useGatewayStore } from '@/stores/gateway';
import { useAgentsStore } from '@/stores/agents';
import { useChatStore } from '@/stores/chat';
import type { AgentSummary } from '@/types/agent';
import { useTranslation } from 'react-i18next';
// ── Types ────────────────────────────────────────────────────────
@@ -32,12 +29,29 @@ export interface FileAttachment {
error?: string;
}
export interface KnowledgeContextDocument {
id: string;
name: string;
mimeType: string;
storedPath: string;
textPath?: string;
}
const EMPTY_KNOWLEDGE_DOCUMENTS: KnowledgeContextDocument[] = [];
interface ChatInputProps {
onSend: (text: string, attachments?: FileAttachment[], targetAgentId?: string | null) => void;
onSend: (
text: string,
attachments?: FileAttachment[],
targetAgentId?: string | null,
options?: { useKnowledgeBase?: boolean; workspaceId?: string; selectedKnowledgeDocumentIds?: string[] },
) => void;
onStop?: () => void;
disabled?: boolean;
sending?: boolean;
isEmpty?: boolean;
knowledgeDocuments?: KnowledgeContextDocument[];
workspaceId?: string;
}
// ── Helpers ──────────────────────────────────────────────────────
@@ -84,31 +98,26 @@ function readFileAsBase64(file: globalThis.File): Promise<string> {
// ── Component ────────────────────────────────────────────────────
export function ChatInput({ onSend, onStop, disabled = false, sending = false, isEmpty = false }: ChatInputProps) {
export function ChatInput({
onSend,
onStop,
disabled = false,
sending = false,
isEmpty = false,
knowledgeDocuments = EMPTY_KNOWLEDGE_DOCUMENTS,
workspaceId,
}: ChatInputProps) {
const { t } = useTranslation('chat');
const [input, setInput] = useState('');
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const [targetAgentId, setTargetAgentId] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [knowledgePickerOpen, setKnowledgePickerOpen] = useState(false);
const [selectedKnowledgeIds, setSelectedKnowledgeIds] = useState<string[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const knowledgePickerRef = useRef<HTMLDivElement>(null);
const isComposingRef = useRef(false);
const gatewayStatus = useGatewayStore((s) => s.status);
const agents = useAgentsStore((s) => s.agents);
const currentAgentId = useChatStore((s) => s.currentAgentId);
const currentAgentName = useMemo(
() => (agents ?? []).find((agent) => agent.id === currentAgentId)?.name ?? currentAgentId,
[agents, currentAgentId],
);
const mentionableAgents = useMemo(
() => (agents ?? []).filter((agent) => agent.id !== currentAgentId),
[agents, currentAgentId],
);
const selectedTarget = useMemo(
() => (agents ?? []).find((agent) => agent.id === targetAgentId) ?? null,
[agents, targetAgentId],
);
const showAgentPicker = mentionableAgents.length > 0;
const knowledgeBaseAvailable = knowledgeDocuments.length > 0;
const selectedKnowledgeDocuments = knowledgeDocuments.filter((doc) => selectedKnowledgeIds.includes(doc.id));
// Auto-resize textarea
useEffect(() => {
@@ -126,30 +135,24 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
}, [disabled]);
useEffect(() => {
if (!targetAgentId) return;
if (targetAgentId === currentAgentId) {
setTargetAgentId(null);
setPickerOpen(false);
return;
}
if (!(agents ?? []).some((agent) => agent.id === targetAgentId)) {
setTargetAgentId(null);
setPickerOpen(false);
}
}, [agents, currentAgentId, targetAgentId]);
setSelectedKnowledgeIds((current) => {
const next = current.filter((id) => knowledgeDocuments.some((doc) => doc.id === id));
return next.length === current.length ? current : next;
});
}, [knowledgeDocuments]);
useEffect(() => {
if (!pickerOpen) return;
if (!knowledgePickerOpen) return;
const handlePointerDown = (event: MouseEvent) => {
if (!pickerRef.current?.contains(event.target as Node)) {
setPickerOpen(false);
if (!knowledgePickerRef.current?.contains(event.target as Node)) {
setKnowledgePickerOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
};
}, [pickerOpen]);
}, [knowledgePickerOpen]);
// ── File staging via native dialog ─────────────────────────────
@@ -307,10 +310,13 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
onSend(textToSend, attachmentsToSend, targetAgentId);
setTargetAgentId(null);
setPickerOpen(false);
}, [input, attachments, canSend, onSend, targetAgentId]);
onSend(textToSend, attachmentsToSend, null, {
useKnowledgeBase: selectedKnowledgeIds.length > 0,
workspaceId,
selectedKnowledgeDocumentIds: selectedKnowledgeIds,
});
setKnowledgePickerOpen(false);
}, [input, attachments, canSend, onSend, selectedKnowledgeIds, workspaceId]);
const handleStop = useCallback(() => {
if (!canStop) return;
@@ -319,10 +325,6 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Backspace' && !input && targetAgentId) {
setTargetAgentId(null);
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
const nativeEvent = e.nativeEvent as KeyboardEvent;
if (isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229) {
@@ -332,9 +334,17 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
handleSend();
}
},
[handleSend, input, targetAgentId],
[handleSend],
);
const toggleKnowledgeDocument = useCallback((id: string) => {
setSelectedKnowledgeIds((current) => (
current.includes(id)
? current.filter((item) => item !== id)
: [...current, id]
));
}, []);
// Handle paste (Ctrl/Cmd+V with files)
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
@@ -386,8 +396,8 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
return (
<div
className={cn(
"p-4 pb-6 w-full mx-auto transition-all duration-300",
isEmpty ? "max-w-3xl" : "max-w-4xl"
"w-full shrink-0 px-0 pb-0 pt-3 mx-auto transition-all duration-300",
isEmpty ? "max-w-5xl" : "max-w-5xl"
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -409,17 +419,21 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
{/* Input Container */}
<div className={`relative bg-white dark:bg-card rounded-2xl shadow-sm border px-3 pt-2.5 pb-1.5 transition-all ${dragOver ? 'border-primary ring-1 ring-primary' : 'border-black/10 dark:border-white/10'}`}>
{selectedTarget && (
<div className="pb-1.5">
<button
type="button"
onClick={() => setTargetAgentId(null)}
className="inline-flex items-center gap-1.5 rounded-lg border border-primary/20 bg-primary/5 px-2.5 py-1 text-[13px] font-medium text-foreground transition-colors hover:bg-primary/10"
title={t('composer.clearTarget')}
>
<span>{t('composer.targetChip', { agent: selectedTarget.name })}</span>
<X className="h-3 w-3 text-muted-foreground" />
</button>
{selectedKnowledgeDocuments.length > 0 && (
<div className="flex flex-wrap gap-1.5 pb-1.5">
{selectedKnowledgeDocuments.map((doc) => (
<button
key={doc.id}
type="button"
onClick={() => toggleKnowledgeDocument(doc.id)}
className="inline-flex max-w-[220px] items-center gap-1.5 rounded-lg border border-[#1E3A8A]/20 bg-[#1E3A8A]/5 px-2.5 py-1 text-[13px] font-medium text-foreground transition-colors hover:bg-[#1E3A8A]/10"
title={doc.name}
>
<BookOpen className="h-3 w-3 shrink-0 text-[#1E3A8A]" />
<span className="truncate">{doc.name}</span>
<X className="h-3 w-3 shrink-0 text-muted-foreground" />
</button>
))}
</div>
)}
@@ -457,44 +471,75 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
<Paperclip className="h-3.5 w-3.5" />
</Button>
{showAgentPicker && (
<div ref={pickerRef} className="relative shrink-0">
<Button
variant="ghost"
size="icon"
className={cn(
'h-8 w-8 rounded-lg text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-colors',
(pickerOpen || selectedTarget) && 'bg-primary/10 text-primary hover:bg-primary/20'
)}
onClick={() => setPickerOpen((open) => !open)}
disabled={disabled || sending}
title={t('composer.pickAgent')}
>
<AtSign className="h-3.5 w-3.5" />
</Button>
{pickerOpen && (
<div className="absolute left-0 bottom-full z-20 mb-2 w-72 overflow-hidden rounded-2xl border border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-card">
<div className="px-3 py-2 text-[11px] font-medium text-muted-foreground/80">
{t('composer.agentPickerTitle', { currentAgent: currentAgentName })}
</div>
<div className="max-h-64 overflow-y-auto">
{mentionableAgents.map((agent) => (
<AgentPickerItem
key={agent.id}
agent={agent}
selected={agent.id === targetAgentId}
onSelect={() => {
setTargetAgentId(agent.id);
setPickerOpen(false);
textareaRef.current?.focus();
}}
/>
))}
</div>
</div>
<div ref={knowledgePickerRef} className="relative shrink-0">
<Button
variant="ghost"
className={cn(
'h-8 rounded-lg px-2 text-[12px] text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-colors',
selectedKnowledgeIds.length > 0 && 'bg-[#1E3A8A]/10 text-[#1E3A8A] hover:bg-[#1E3A8A]/15',
)}
</div>
)}
onClick={() => setKnowledgePickerOpen((open) => !open)}
disabled={!knowledgeBaseAvailable || disabled || sending}
data-testid="chat-composer-knowledge-button"
title={knowledgeBaseAvailable ? '选择知识库文件作为上下文' : '当前组织空间暂无知识文件'}
>
<BookOpen className="mr-1.5 h-3.5 w-3.5" />
<span className="ml-1 text-muted-foreground">
{selectedKnowledgeIds.length > 0 ? selectedKnowledgeIds.length : knowledgeDocuments.length}
</span>
<ChevronDown className="ml-1 h-3 w-3" />
</Button>
{knowledgePickerOpen && (
<div
data-testid="chat-composer-knowledge-popover"
className="absolute bottom-full left-0 z-30 mb-2 w-80 max-w-[calc(100vw-7rem)] overflow-hidden rounded-xl border border-black/10 bg-white p-2 shadow-xl dark:border-white/10 dark:bg-card"
>
<div className="flex items-center justify-between px-2 py-1.5">
<div className="text-[12px] font-medium text-foreground"></div>
{selectedKnowledgeIds.length > 0 && (
<button
type="button"
onClick={() => setSelectedKnowledgeIds([])}
className="text-[11px] text-muted-foreground transition-colors hover:text-foreground"
>
</button>
)}
</div>
<div className="max-h-64 overflow-y-auto">
{knowledgeDocuments.map((doc) => {
const selected = selectedKnowledgeIds.includes(doc.id);
return (
<button
key={doc.id}
type="button"
onClick={() => toggleKnowledgeDocument(doc.id)}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors',
selected ? 'bg-[#1E3A8A]/10 text-foreground' : 'hover:bg-black/5 dark:hover:bg-white/5',
)}
>
<span className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
selected ? 'border-[#1E3A8A] bg-[#1E3A8A] text-white' : 'border-slate-300 text-transparent',
)}>
<Check className="h-3 w-3" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">{doc.name}</span>
<span className="block truncate text-[11px] text-muted-foreground">
{doc.textPath ? '已提取文本' : doc.mimeType}
</span>
</span>
</button>
);
})}
</div>
</div>
)}
</div>
{/* Send Button — pushed to the right */}
<Button
@@ -609,29 +654,3 @@ function AttachmentPreview({
</div>
);
}
function AgentPickerItem({
agent,
selected,
onSelect,
}: {
agent: AgentSummary;
selected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
'flex w-full flex-col items-start rounded-xl px-3 py-2 text-left transition-colors',
selected ? 'bg-primary/10 text-foreground' : 'hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<span className="text-[14px] font-medium text-foreground">{agent.name}</span>
<span className="text-[11px] text-muted-foreground">
{agent.modelDisplay}
</span>
</button>
);
}

View File

@@ -5,7 +5,7 @@
* surfaced via ExecutionGraphCard, not inside message bubbles.
*/
import { useState, useCallback, useEffect, memo } from 'react';
import { Sparkles, Copy, Check, ChevronDown, ChevronRight, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
import { Copy, Check, ChevronDown, ChevronRight, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
@@ -16,6 +16,7 @@ import { cn } from '@/lib/utils';
import { invokeIpc } from '@/lib/api-client';
import type { RawMessage, AttachedFileMeta } from '@/stores/chat';
import { extractText, extractImages, extractToolUse, formatTimestamp } from './message-utils';
import assistantLogo from '@/assets/logo.svg';
interface ChatMessageProps {
message: RawMessage;
@@ -125,8 +126,8 @@ export const ChatMessage = memo(function ChatMessage({
>
{/* Avatar */}
{!isUser && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full mt-1 bg-black/5 dark:bg-white/5 text-foreground">
<Sparkles className="h-4 w-4" />
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-sky-100 bg-[#ECFBFF] shadow-sm dark:border-white/10 dark:bg-slate-900">
<img src={assistantLogo} alt="智念助手" className="h-7 w-7 rounded-full" />
</div>
)}

View File

@@ -1,35 +1,22 @@
/**
* Chat Toolbar
* Session selector, new session, and refresh.
* Chat refresh controls.
* Rendered in the Header when on the Chat page.
*/
import { useMemo } from 'react';
import { RefreshCw, Bot } from 'lucide-react';
import { RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useChatStore } from '@/stores/chat';
import { useAgentsStore } from '@/stores/agents';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
export function ChatToolbar() {
const refresh = useChatStore((s) => s.refresh);
const loading = useChatStore((s) => s.loading);
const currentAgentId = useChatStore((s) => s.currentAgentId);
const agents = useAgentsStore((s) => s.agents);
const { t } = useTranslation('chat');
const currentAgentName = useMemo(
() => (agents ?? []).find((agent) => agent.id === currentAgentId)?.name ?? currentAgentId,
[agents, currentAgentId],
);
return (
<div className="flex items-center gap-2">
<div className="hidden sm:flex items-center gap-1.5 rounded-full border border-black/10 bg-white/70 px-3 py-1.5 text-[12px] font-medium text-foreground/80 dark:border-white/10 dark:bg-white/5">
<Bot className="h-3.5 w-3.5 text-primary" />
<span>{t('toolbar.currentAgent', { agent: currentAgentName })}</span>
</div>
{/* Refresh */}
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@@ -9,10 +9,12 @@ import { AlertCircle, Loader2, Sparkles } from 'lucide-react';
import { useChatStore, type RawMessage } from '@/stores/chat';
import { useGatewayStore } from '@/stores/gateway';
import { useAgentsStore } from '@/stores/agents';
import { useYinianStore } from '@/stores/yinian';
import { hostApiFetch } from '@/lib/host-api';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput';
import type { KnowledgeContextDocument } from './ChatInput';
import { ExecutionGraphCard } from './ExecutionGraphCard';
import { ChatToolbar } from './ChatToolbar';
import { extractImages, extractText, extractThinking, extractToolUse, stripProcessMessagePrefix } from './message-utils';
@@ -88,9 +90,12 @@ export function Chat() {
const clearError = useChatStore((s) => s.clearError);
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
const agents = useAgentsStore((s) => s.agents);
const yinianConfig = useYinianStore((s) => s.config);
const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession);
const [childTranscripts, setChildTranscripts] = useState<Record<string, RawMessage[]>>({});
const [knowledgeDocuments, setKnowledgeDocuments] = useState<KnowledgeContextDocument[]>([]);
const workspaceId = yinianConfig?.hotel.id;
// Persistent per-run override for the Execution Graph's expanded/collapsed
// state. Keyed by a stable run id (trigger message id, or a fallback of
// `${sessionKey}:${triggerIdx}`) so user toggles survive the `loadHistory`
@@ -119,6 +124,26 @@ export function Chat() {
void fetchAgents();
}, [fetchAgents]);
useEffect(() => {
if (!workspaceId) {
setKnowledgeDocuments([]);
return;
}
let cancelled = false;
void hostApiFetch<{ documents: KnowledgeContextDocument[] }>(`/api/knowledge/files?workspaceId=${encodeURIComponent(workspaceId)}`)
.then((result) => {
if (!cancelled) setKnowledgeDocuments(result.documents ?? []);
})
.catch(() => {
if (!cancelled) setKnowledgeDocuments([]);
});
return () => {
cancelled = true;
};
}, [workspaceId]);
useEffect(() => {
const completions = messages
.map((message) => parseSubagentCompletionInfo(message))
@@ -569,21 +594,21 @@ export function Chat() {
}, [userRunCards, messages, currentSessionKey]);
return (
<div className={cn("relative flex min-h-0 flex-col -m-6 transition-colors duration-500 dark:bg-background")} style={{ height: 'calc(100vh - 2.5rem)' }}>
<div className={cn("relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden transition-colors duration-500 dark:bg-background")}>
{/* Toolbar */}
<div className="flex shrink-0 items-center justify-end px-4 py-2">
<div className="flex shrink-0 items-center justify-end pb-3">
<ChatToolbar />
</div>
{/* Messages Area */}
<div className="min-h-0 flex-1 overflow-hidden px-4 py-4">
<div className="mx-auto flex h-full min-h-0 max-w-6xl flex-col gap-4 lg:flex-row lg:items-stretch">
<div className="min-h-0 min-w-0 flex-1 overflow-hidden pb-4">
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-4 lg:flex-row lg:items-stretch">
<div ref={scrollRef} className="min-h-0 min-w-0 flex-1 overflow-y-auto">
<div
ref={contentRef}
className={cn(
"space-y-4 transition-all duration-300",
isEmpty ? "mx-auto w-full max-w-3xl" : "max-w-4xl",
isEmpty ? "mx-auto w-full max-w-5xl" : "mx-auto w-full max-w-5xl",
)}
>
{isEmpty ? (
@@ -700,7 +725,7 @@ export function Chat() {
{/* Error bar */}
{error && (
<div className="px-4 py-2 bg-destructive/10 border-t border-destructive/20">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<div className="mx-auto flex max-w-5xl items-center justify-between">
<p className="text-sm text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
{error}
@@ -722,6 +747,8 @@ export function Chat() {
disabled={!isGatewayRunning}
sending={sending || hasActiveExecutionGraph}
isEmpty={isEmpty}
knowledgeDocuments={knowledgeDocuments}
workspaceId={workspaceId}
/>
{/* Transparent loading overlay */}
@@ -740,28 +767,12 @@ export function Chat() {
function WelcomeScreen() {
const { t } = useTranslation('chat');
const quickActions = [
{ key: 'askQuestions', label: t('welcome.askQuestions') },
{ key: 'creativeTasks', label: t('welcome.creativeTasks') },
{ key: 'brainstorming', label: t('welcome.brainstorming') },
];
return (
<div className="flex flex-col items-center justify-center text-center h-[60vh]">
<h1 className="text-4xl md:text-5xl font-serif text-foreground/80 mb-8 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
<h1 className="mb-8 text-3xl font-semibold tracking-normal text-foreground/85 md:text-4xl">
{t('welcome.subtitle')}
</h1>
<div className="flex flex-wrap items-center justify-center gap-2.5 max-w-lg w-full">
{quickActions.map(({ key, label }) => (
<button
key={key}
className="px-4 py-1.5 rounded-full border border-black/10 dark:border-white/10 text-[13px] font-medium text-foreground/70 hover:bg-black/5 dark:hover:bg-white/5 transition-colors bg-black/[0.02]"
>
{label}
</button>
))}
</div>
</div>
);
}

View File

@@ -20,6 +20,9 @@ function cleanUserText(text: string): string {
.replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
// Fallback: remove "Conversation info (...): {...}" without code block wrapper
.replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '')
// Remove channel ingress metadata prefix like:
// System: [2026-04-27 11:20:03 GMT+8] Feishu[default] DM | ou_xxx [msg:om_xxx]
.replace(/^\s*System\s*:\s*\[\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+GMT[+-]\d+\]\s+[A-Za-z][\w -]*(?:\[[^\]]+\])?\s+(?:DM|Group|Room|Chat|Message)\s+\|\s+\S+(?:\s+\[msg:[^\]]+\])?\s*/i, '')
// Remove Gateway timestamp prefix like [Fri 2026-02-13 22:39 GMT+8]
.replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '')
.trim();