import { useCallback, useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { Eye, RefreshCw, X } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize from 'rehype-sanitize'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentSelect } from '../../../components/lucent/LucentSelect'; import { createWorkspaceMarkdownComponents, decorateWorkspacePathsForMarkdown } from '../shared/workspaceMarkdown'; export interface TopicFeedItem { id: number; bot_id: string; topic_key: string; title: string; content: string; level: string; tags: string[]; view: Record; source: string; dedupe_key: string; is_read: boolean; created_at?: string; } export interface TopicFeedOption { key: string; label: string; } interface TopicFeedPanelProps { isZh: boolean; topicKey: string; topicOptions: TopicFeedOption[]; topicState?: 'none' | 'inactive' | 'ready'; items: TopicFeedItem[]; loading: boolean; loadingMore: boolean; nextCursor: number | null; error: string; readSavingById: Record; onTopicChange: (value: string) => void; onRefresh: () => void; onMarkRead: (itemId: number) => void; onLoadMore: () => void; onOpenWorkspacePath: (path: string) => void; onOpenTopicSettings?: () => void; onDetailOpenChange?: (open: boolean) => void; layout?: 'compact' | 'panel'; } interface TopicSummaryCard { title: string; summary: string; highlights: string[]; snippet: string; } interface TopicDetailState { itemId: number; fallbackTitle: string; fallbackContent: string; } function formatTopicItemTime(raw: string | undefined, isZh: boolean): string { const text = String(raw || '').trim(); if (!text) return '-'; const dt = new Date(text); if (Number.isNaN(dt.getTime())) return '-'; try { return dt.toLocaleString(isZh ? 'zh-CN' : 'en-US', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }); } catch { return dt.toLocaleString(); } } function cleanTopicLine(raw: unknown): string { const text = String(raw || '').trim(); if (!text) return ''; if (/^\|.*\|$/.test(text)) return ''; if (/^[-=:_`~]{3,}$/.test(text)) return ''; return text.replace(/^\s{0,3}(?:[#>*-]+|\d+[.)])\s*/, '').trim(); } function deriveTopicSummaryCard(item: TopicFeedItem): TopicSummaryCard { const view = item.view && typeof item.view === 'object' ? item.view : {}; const titleFromView = String(view.title || '').trim(); const summaryFromView = String(view.summary || '').trim(); const snippetFromView = String(view.snippet || '').trim(); const highlightsFromView = Array.isArray(view.highlights) ? view.highlights.map((row) => String(row || '').trim()).filter(Boolean) : []; const lines = String(item.content || '') .split('\n') .map(cleanTopicLine) .filter(Boolean); const title = titleFromView || String(item.title || '').trim() || lines[0] || item.topic_key || 'Topic'; const summaryCandidates = lines.filter((line) => line !== title); const summary = summaryFromView || summaryCandidates.slice(0, 2).join(' ').slice(0, 220).trim() || title; const fallbackHighlights = String(item.content || '') .split('\n') .map((line) => ({ raw: String(line || '').trim(), cleaned: cleanTopicLine(line) })) .filter((row) => row.cleaned) .filter((row) => row.raw.startsWith('-') || row.raw.startsWith('*') || row.cleaned.includes(':') || row.cleaned.includes(':')) .map((row) => row.cleaned.slice(0, 120)) .filter((row, idx, arr) => arr.indexOf(row) === idx) .slice(0, 3); const snippetCandidates = summaryCandidates .filter((line) => line !== summary) .filter((line) => !fallbackHighlights.includes(line)) .slice(0, 2); const snippet = snippetFromView || snippetCandidates.join(' ').slice(0, 180).trim(); return { title, summary, highlights: highlightsFromView.length > 0 ? highlightsFromView.slice(0, 3) : fallbackHighlights, snippet, }; } export function TopicFeedPanel({ isZh, topicKey, topicOptions, topicState = 'ready', items, loading, loadingMore, nextCursor, error, readSavingById, onTopicChange, onRefresh, onMarkRead, onLoadMore, onOpenWorkspacePath, onOpenTopicSettings, onDetailOpenChange, layout = 'compact', }: TopicFeedPanelProps) { const markdownComponents = useMemo( () => createWorkspaceMarkdownComponents((path) => onOpenWorkspacePath(path)), [onOpenWorkspacePath], ); const [detailState, setDetailState] = useState(null); const closeDetail = useCallback(() => setDetailState(null), []); const detailItem = useMemo( () => (detailState ? items.find((item) => Number(item.id || 0) === detailState.itemId) || null : null), [detailState, items], ); const detailTitle = detailItem ? String(detailItem.title || detailItem.topic_key || detailState?.fallbackTitle || '').trim() : String(detailState?.fallbackTitle || '').trim(); const detailContent = detailItem ? String(detailItem.content || detailState?.fallbackContent || '').trim() : String(detailState?.fallbackContent || '').trim(); const portalTarget = useMemo(() => { if (typeof document === 'undefined') return null; return document.querySelector('.app-shell[data-theme]') || document.body; }, []); useEffect(() => { if (!detailState) return; const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { closeDetail(); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [closeDetail, detailState]); useEffect(() => { onDetailOpenChange?.(Boolean(detailState)); }, [detailState, onDetailOpenChange]); return (
{topicState === 'ready' ? (
onTopicChange(String(e.target.value || '__all__'))}> {topicOptions.map((row) => ( ))}
) : null} {error ?
{error}
: null}
{topicState === 'none' ? (
{isZh ? '还没有配置主题' : 'No topics configured'}
{isZh ? '请先到主题设置中新增至少一个主题,Bot 的消息才能被路由到这里。' : 'Create at least one topic in settings before feed messages can appear here.'}
{onOpenTopicSettings ? ( ) : null}
) : topicState === 'inactive' ? (
{isZh ? '没有启用中的主题' : 'No active topics'}
{isZh ? '你已经配置了主题,但当前都处于关闭状态。启用一个主题后,这里才会开始接收消息。' : 'Topics exist, but all of them are disabled. Enable one to start receiving feed items here.'}
{onOpenTopicSettings ? ( ) : null}
) : loading ? (
{isZh ? '读取主题消息中...' : 'Loading topic feed...'}
) : items.length === 0 ? (
{isZh ? '暂无主题消息。' : 'No topic messages.'}
) : ( items.map((item) => { const itemId = Number(item.id || 0); const level = String(item.level || 'info').trim().toLowerCase(); const levelText = level === 'warn' ? 'WARN' : level === 'error' ? 'ERROR' : level === 'success' ? 'SUCCESS' : 'INFO'; const unread = !Boolean(item.is_read); const card = deriveTopicSummaryCard(item); const rawContent = String(item.content || '').trim(); return (
{levelText} {item.topic_key || '-'} {unread ? : null}
{item.source || 'mcp'} {formatTopicItemTime(item.created_at, isZh)}
{card.title}
{card.summary}
{card.highlights.length > 0 ? (
{card.highlights.map((line) => (
{line}
))}
) : null} {card.snippet ?
{card.snippet}
: null} {rawContent ? null : null}
{(item.tags || []).length > 0 ? (
{(item.tags || []).map((tag) => ( {tag} ))}
) : null}
{unread ? (isZh ? '新消息' : 'New') : (isZh ? '已读' : 'Read')}
{rawContent ? ( setDetailState({ itemId, fallbackTitle: card.title, fallbackContent: rawContent })} tooltip={isZh ? '查看详情' : 'View details'} aria-label={isZh ? '查看详情' : 'View details'} > ) : null} {unread ? ( ) : null}
); }) )}
{detailState && portalTarget ? createPortal(
event.stopPropagation()}>

{isZh ? '主题详情' : 'Topic detail'}

{detailTitle || (isZh ? '原文详情' : 'Raw detail')}
{decorateWorkspacePathsForMarkdown(detailContent)}
, portalTarget, ) : null} {topicState === 'ready' && (items.length > 0 || nextCursor) ? (
{items.length > 0 ? (isZh ? `共 ${items.length} 条` : `${items.length} items`) : ''} {nextCursor ? ( ) : ( )}
) : null}
); }