import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Plus, Trash2, Sparkles, RefreshCw, ChevronRight, Loader, Settings2, X, Download, Upload } from 'lucide-react' import dayjs from 'dayjs' import { getGroups, createGroup, patchGroup, initGroup, deleteGroup, getTopics, getTopic, createTopic, deleteTopic, summarizeTopic, addTopicMessage, removeTopicMessage, getTask, getChatrooms, getChatlog, getChatroomMembers, } from '../api' import MessageBubble from '../components/MessageBubble' import ReportDocumentView from '../components/ReportDocumentView' import { exportWordDoc } from '../utils/wordExport' import { uploadToKnowledgeBase, getWanchuanKnowledgeBases, getBoundKnowledgeBase } from '../api/wanchuan' const TYPE_MAP = { 1: 0, 3: 1, 34: 2, 43: 3, 47: 5, 49: 7, 10000: 80, 10002: 81, } const ANALYZE_PRESETS = [ { label: '今天', getDates: () => [dayjs().startOf('day'), dayjs()] }, { label: '昨天', getDates: () => [dayjs().subtract(1, 'day').startOf('day'), dayjs().subtract(1, 'day').endOf('day')] }, { label: '近7天', getDates: () => [dayjs().subtract(7, 'day').startOf('day'), dayjs()] }, { label: '近30天', getDates: () => [dayjs().subtract(30, 'day').startOf('day'), dayjs()] }, ] function convertTopicMsgType(rawType, subType) { if (Number(rawType) === 49 && Number(subType) === 62) return 82 return TYPE_MAP[Number(rawType)] ?? 0 } function toMsgBubbleFormat(m, index) { const rawType = Number(m.type ?? 1) const subType = Number(m.sub_type ?? m.subType ?? 0) const isFile = Boolean(m.is_file) || (rawType === 49 && subType === 6) const fileMd5 = m.file_md5 || m.fileMd5 || m.contents?.md5 || '' const fileName = m.file_name || m.fileName || m.contents?.title || '' const displayName = ( m.sender_name || m.senderName || m.SenderName || m.groupNickname || m.accountName || m.sender || '未知' ) return { id: String(m.seq || m.id || index), sender: m.sender || '', accountName: displayName, groupNickname: displayName, timestamp: m.create_time ? dayjs(m.create_time).unix() : (m.timestamp || 0), type: convertTopicMsgType(rawType, subType), content: m.content || '', subType, mediaKey: m.media_key || m.mediaKey || '', voiceKey: m.voice_key || m.voiceKey || '', mediaMd5: m.media_key || m.mediaMd5 || '', mediaPath: m.mediaPath || '', emojiUrl: m.contents?.cdnurl || m.emojiUrl || '', linkTitle: isFile ? '' : (m.link_title || m.linkTitle || m.contents?.title || ''), linkDesc: isFile ? '' : (m.link_desc || m.linkDesc || m.contents?.desc || ''), linkUrl: isFile ? '' : (m.link_url || m.linkUrl || m.contents?.url || m.content || ''), linkThumb: isFile ? '' : (m.link_thumb || m.linkThumb || m.contents?.thumbUrl || ''), linkSource: isFile ? '' : (m.link_source || m.linkSource || m.contents?.sourceName || ''), quote: m.quote || null, isFile, fileName, fileMd5, fileUrl: m.file_url || m.fileUrl || (fileMd5 ? `/api/files/${encodeURIComponent(fileMd5)}?filename=${encodeURIComponent(fileName || fileMd5)}` : ''), } } function topicMsgPreview(m) { return m.content || m.link_title || m.linkTitle || m.file_name || m.fileName || m.contents?.title || m.quote?.content || '[媒体消息]' } function compactText(value, max = 28) { const text = String(value || '').replace(/\s+/g, ' ').trim() if (!text) return '' return text.length > max ? `${text.slice(0, max)}...` : text } export default function TopicsPage({ sessions = [], onToast }) { const [groups, setGroups] = useState([]) const [selectedGroup, setSelectedGroup] = useState(null) const [topics, setTopics] = useState([]) const [selectedTopic, setSelectedTopic] = useState(null) const [topicDetail, setTopicDetail] = useState(null) const [loading, setLoading] = useState(false) const [newTopicTitle, setNewTopicTitle] = useState('') const [showAddGroup, setShowAddGroup] = useState(false) const [selectedTalker, setSelectedTalker] = useState('') const [addingGroup, setAddingGroup] = useState(false) const [deletingGroupId, setDeletingGroupId] = useState(null) const [addGroupKeyword, setAddGroupKeyword] = useState('') const [groupSearchResults, setGroupSearchResults] = useState([]) const [groupSearchLoading, setGroupSearchLoading] = useState(false) const [groupSearchError, setGroupSearchError] = useState('') const [showGroupDropdown, setShowGroupDropdown] = useState(false) const groupSearchSeqRef = useRef(0) const [hoveredGroupId, setHoveredGroupId] = useState(null) const [showMsgManager, setShowMsgManager] = useState(false) const [allMsgs, setAllMsgs] = useState([]) const [msgSearch, setMsgSearch] = useState('') const [msgLoading, setMsgLoading] = useState(false) const [msgStartDate, setMsgStartDate] = useState(dayjs().subtract(7, 'day').startOf('day').format('YYYY-MM-DDTHH:mm')) const [msgEndDate, setMsgEndDate] = useState(dayjs().format('YYYY-MM-DDTHH:mm')) const [msgMembers, setMsgMembers] = useState([]) const [msgAllMembers, setMsgAllMembers] = useState([]) const [analyzeStartDate, setAnalyzeStartDate] = useState(dayjs().subtract(7, 'day').startOf('day').format('YYYY-MM-DDTHH:mm')) const [analyzeEndDate, setAnalyzeEndDate] = useState(dayjs().format('YYYY-MM-DDTHH:mm')) const [analyzePreset, setAnalyzePreset] = useState('近7天') const [initStateByGroup, setInitStateByGroup] = useState({}) const [summarizing, setSummarizing] = useState(false) const [summarizeProgress, setSummarizeProgress] = useState(null) const [showGroupPrompt, setShowGroupPrompt] = useState(false) const [groupPromptDraft, setGroupPromptDraft] = useState('') const [savingGroupPrompt, setSavingGroupPrompt] = useState(false) const [uploadingToKb, setUploadingToKb] = useState(false) const loadGroups = useCallback(async () => { try { const data = await getGroups() setGroups(Array.isArray(data) ? data : []) return Array.isArray(data) ? data : [] } catch { setGroups([]) return [] } }, []) const resetAddGroupState = useCallback(() => { groupSearchSeqRef.current += 1 setSelectedTalker('') setAddGroupKeyword('') setGroupSearchResults([]) setGroupSearchError('') setGroupSearchLoading(false) setShowGroupDropdown(false) }, []) const openAddGroup = useCallback(() => { resetAddGroupState() setShowAddGroup(true) setShowGroupDropdown(true) }, [resetAddGroupState]) const closeAddGroup = useCallback(() => { setShowAddGroup(false) resetAddGroupState() }, [resetAddGroupState]) useEffect(() => { loadGroups() }, [loadGroups]) const addGroupKeywordText = addGroupKeyword.trim() const recentGroupResults = useMemo(() => { const monitored = new Set() groups.forEach((group) => { if (group?.talker) monitored.add(group.talker) if (group?.id) monitored.add(group.id) }) return sessions .filter((room) => room?.id && room.isGroup && !monitored.has(room.id)) .slice() .sort((a, b) => (Number(b.lastTime) || 0) - (Number(a.lastTime) || 0)) .slice(0, 100) }, [groups, sessions]) const displayGroupResults = addGroupKeywordText ? groupSearchResults : recentGroupResults useEffect(() => { if (!showAddGroup) return undefined const keyword = addGroupKeywordText if (!keyword) { groupSearchSeqRef.current += 1 setGroupSearchResults([]) setGroupSearchError('') setGroupSearchLoading(false) return undefined } const seq = groupSearchSeqRef.current + 1 groupSearchSeqRef.current = seq setGroupSearchLoading(true) setGroupSearchError('') const timer = setTimeout(async () => { try { const res = await getChatrooms(keyword) if (groupSearchSeqRef.current !== seq) return const monitored = new Set(groups.map((g) => g.talker)) const items = (res?.data || []) .filter((room) => room?.id && !monitored.has(room.id)) .slice(0, 100) setGroupSearchResults(items) } catch (e) { if (groupSearchSeqRef.current !== seq) return setGroupSearchResults([]) setGroupSearchError(e?.response?.data?.detail || e?.message || '搜索失败') } finally { if (groupSearchSeqRef.current === seq) setGroupSearchLoading(false) } }, 250) return () => clearTimeout(timer) }, [showAddGroup, addGroupKeywordText, groups]) const loadTopics = useCallback(async (groupId) => { setLoading(true) setTopics([]) setSelectedTopic(null) setTopicDetail(null) try { const data = await getTopics({ group_id: groupId }) setTopics(Array.isArray(data) ? data : []) } catch { setTopics([]) } finally { setLoading(false) } }, []) const handleSelectGroup = useCallback((group) => { setSelectedGroup(group) loadTopics(group.id) }, [loadTopics]) const openGroupPrompt = () => { if (!selectedGroup) return setGroupPromptDraft(selectedGroup.analysis_prompt || '') setShowGroupPrompt(true) } const handleSaveGroupPrompt = async () => { if (!selectedGroup || savingGroupPrompt) return setSavingGroupPrompt(true) try { const updated = await patchGroup(selectedGroup.id, { analysis_prompt: groupPromptDraft }) const groupData = updated?.data ?? updated setGroups((prev) => prev.map((g) => (g.id === selectedGroup.id ? { ...g, ...groupData } : g))) setSelectedGroup((prev) => prev ? { ...prev, ...groupData } : prev) setShowGroupPrompt(false) onToast?.('AI 分析提示词已保存') } catch (e) { onToast?.(e?.response?.data?.detail || e?.message || '保存提示词失败', 'error') } finally { setSavingGroupPrompt(false) } } const handleDeleteGroup = async (e, group) => { e.stopPropagation() if (deletingGroupId) return if (!window.confirm(`确定删除“${group.name || group.talker}”吗?\n相关话题和报告也会一起删除。`)) return setDeletingGroupId(group.id) try { await deleteGroup(group.id) setGroups((prev) => prev.filter((g) => g.id !== group.id)) if (selectedGroup?.id === group.id) { setSelectedGroup(null) setTopics([]) setSelectedTopic(null) setTopicDetail(null) } resetAddGroupState() onToast?.('群聊已删除') } catch (e2) { onToast?.(e2?.response?.data?.detail || e2?.message || '删除失败', 'error') } finally { setDeletingGroupId(null) } } const handleAddGroup = async () => { if (!selectedTalker || addingGroup) return const result = groupSearchResults.find((s) => s.id === selectedTalker) const session = result || sessions.find((s) => s.id === selectedTalker) setAddingGroup(true) try { const created = await createGroup(selectedTalker, session?.name || addGroupKeyword || selectedTalker) onToast?.('群聊已添加') closeAddGroup() const nextGroups = await loadGroups() const added = nextGroups.find((g) => g.id === created?.id || g.talker === selectedTalker) || created if (added?.id) handleSelectGroup(added) } catch (e) { if (e?.response?.status === 409) { onToast?.('该群聊已在监控列表中', 'error') loadGroups() } else { onToast?.(e?.response?.data?.detail || e?.message || '添加失败', 'error') } } finally { setAddingGroup(false) } } const applyAnalyzePreset = (preset) => { const [start, end] = preset.getDates() setAnalyzeStartDate(start.format('YYYY-MM-DDTHH:mm')) setAnalyzeEndDate(end.format('YYYY-MM-DDTHH:mm')) setAnalyzePreset(preset.label) } const handleInitGroup = async (group) => { try { const cfg = await fetch('/api/settings').then((r) => r.json()) if (!cfg.ai_api_key) { onToast?.('请先配置 AI API Key', 'error') return } if (!cfg.ai_model) { onToast?.('请先配置话题分析模型', 'error') return } } catch { onToast?.('读取 AI 设置失败', 'error') return } const startTime = dayjs(analyzeStartDate).unix() const endTime = dayjs(analyzeEndDate).unix() if (!startTime || !endTime || endTime <= startTime) { onToast?.('请选择有效的分析时间范围', 'error') return } setInitStateByGroup((prev) => ({ ...prev, [group.id]: { taskId: null, status: 'running', progress: null }, })) let taskId = null try { const res = await initGroup(group.id, { startTime, endTime }) taskId = res?.data?.task_id ?? res?.task_id onToast?.('AI 分析已开始') } catch (e) { const detail = e?.response?.data?.detail || e?.message onToast?.(detail || 'AI 分析启动失败', 'error') setInitStateByGroup((prev) => { const next = { ...prev } delete next[group.id] return next }) return } if (!taskId) { setTimeout(() => { loadTopics(group.id) setInitStateByGroup((prev) => { const next = { ...prev } delete next[group.id] return next }) }, 5000) return } setInitStateByGroup((prev) => ({ ...prev, [group.id]: { taskId, status: 'running', progress: null }, })) let netFails = 0 const poll = setInterval(async () => { try { const task = await getTask(taskId) const taskData = task?.data ?? task netFails = 0 let progress = null try { progress = taskData.progress ? JSON.parse(taskData.progress) : null } catch { progress = null } setInitStateByGroup((prev) => ({ ...prev, [group.id]: { taskId, status: taskData.status || 'running', progress }, })) if (taskData.status === 'done') { clearInterval(poll) setSelectedTopic(null) setTopicDetail(null) loadTopics(group.id) onToast?.('AI 分析完成') setTimeout(() => { setInitStateByGroup((prev) => { const next = { ...prev } delete next[group.id] return next }) }, 800) } else if (taskData.status === 'error') { clearInterval(poll) onToast?.(`AI 分析失败:${taskData.error || '未知错误'}`, 'error') setInitStateByGroup((prev) => { const next = { ...prev } delete next[group.id] return next }) } } catch { netFails += 1 if (netFails >= 5) { clearInterval(poll) setInitStateByGroup((prev) => { const next = { ...prev } delete next[group.id] return next }) } } }, 3000) } const handleSelectTopic = async (topic) => { setSelectedTopic(topic) setTopicDetail(null) try { const detail = await getTopic(topic.id) setTopicDetail(detail) } catch { setTopicDetail(null) } } const handleCreateTopic = async () => { if (!newTopicTitle.trim() || !selectedGroup) return try { await createTopic(selectedGroup.id, newTopicTitle.trim()) setNewTopicTitle('') onToast?.('话题已创建') loadTopics(selectedGroup.id) } catch (e) { onToast?.(e?.response?.data?.detail || e?.message || '创建话题失败', 'error') } } const handleDeleteTopic = async (topic) => { try { await deleteTopic(topic.id) onToast?.('话题已删除') setSelectedTopic(null) setTopicDetail(null) loadTopics(selectedGroup.id) } catch (e) { onToast?.(e?.response?.data?.detail || e?.message || '删除话题失败', 'error') } } const handleOpenMsgManager = async () => { if (!selectedTopic || !selectedGroup) return setShowMsgManager(true) setMsgLoading(true) setAllMsgs([]) setMsgSearch('') const start = dayjs().subtract(7, 'day').startOf('day') const end = dayjs() setMsgStartDate(start.format('YYYY-MM-DDTHH:mm')) setMsgEndDate(end.format('YYYY-MM-DDTHH:mm')) setMsgMembers([]) try { const [msgsRes, membersRes] = await Promise.all([ getChatlog({ talker: selectedGroup.talker, startTime: start.unix(), endTime: end.unix(), limit: 200, offset: 0 }), getChatroomMembers(selectedGroup.talker), ]) setAllMsgs(msgsRes.data?.messages || []) setMsgAllMembers(membersRes.data || []) } catch (e) { onToast?.(e?.response?.data?.detail || e?.message || '加载消息失败', 'error') } finally { setMsgLoading(false) } } const fetchMsgs = async () => { if (!selectedGroup) return setMsgLoading(true) try { const res = await getChatlog({ talker: selectedGroup.talker, startTime: dayjs(msgStartDate).unix(), endTime: dayjs(msgEndDate).unix(), senders: msgMembers, keyword: msgSearch, limit: 200, offset: 0, }) setAllMsgs(res.data?.messages || []) } catch (e) { onToast?.(e?.response?.data?.detail || e?.message || '搜索消息失败', 'error') } finally { setMsgLoading(false) } } const handleAddMsg = async (msg) => { try { await addTopicMessage(selectedTopic.id, Number(msg.id), selectedGroup.talker) const detail = await getTopic(selectedTopic.id) setTopicDetail(detail) } catch (e) { onToast?.(e?.response?.data?.detail || e?.message || '添加消息失败', 'error') } } const handleRemoveMsg = async (seq) => { try { await removeTopicMessage(selectedTopic.id, seq) const detail = await getTopic(selectedTopic.id) setTopicDetail(detail) } catch (e) { onToast?.(e?.response?.data?.detail || e?.message || '移除消息失败', 'error') } } const handleSummarize = async () => { if (!selectedTopic) return if (uploadingToKb) return setSummarizing(true) setSummarizeProgress(null) try { const res = await summarizeTopic(selectedTopic.id) onToast?.('AI 报告生成已开始') const taskId = res?.data?.task_id ?? res?.task_id if (!taskId) { setTimeout(() => { handleSelectTopic(selectedTopic) setSummarizing(false) }, 5000) return } let tries = 0 let handled = false const poll = setInterval(async () => { if (handled) return tries += 1 try { const task = await getTask(taskId) const taskData = task?.data ?? task try { const progress = taskData.progress ? JSON.parse(taskData.progress) : null if (progress) setSummarizeProgress(progress) } catch { // Ignore malformed progress payloads. } if (taskData.status === 'done') { handled = true clearInterval(poll) handleSelectTopic(selectedTopic) onToast?.('AI 报告生成完成') // 自动上传知识库已关闭:报告生成完成后不再自动同步到万川知识库。 // 如需恢复,可在此处重新调用 uploadToKnowledgeBaseIfBound(selectedTopic.title, reportContent)。 setSummarizeProgress(null) setSummarizing(false) return } if (taskData.status === 'error') { handled = true clearInterval(poll) setSummarizeProgress(null) setSummarizing(false) handleSelectTopic(selectedTopic) onToast?.(taskData.error || 'AI 报告生成失败,请检查模型/API 后重试', 'error') return } if (tries > 60) { handled = true clearInterval(poll) setSummarizeProgress(null) setSummarizing(false) handleSelectTopic(selectedTopic) onToast?.('AI 报告生成超时,请稍后重试', 'error') } } catch (e) { if (tries > 60) { handled = true clearInterval(poll) setSummarizeProgress(null) setSummarizing(false) handleSelectTopic(selectedTopic) onToast?.(e?.response?.data?.detail || e?.message || '获取 AI 报告进度失败', 'error') } } }, 3000) } catch (e) { onToast?.(e?.response?.data?.detail || e?.message || '报告生成失败', 'error') setSummarizing(false) setSummarizeProgress(null) } } const handleExportReport = async () => { const content = topicDetail?.knowledge_doc?.content || '' if (!selectedTopic || !content) { onToast?.('暂无可导出的报告内容', 'error') return } try { await exportWordDoc(selectedTopic.title || '话题报告', content) onToast?.('Word 文档已导出') } catch (e) { onToast?.(e?.message || 'Word 导出失败', 'error') } } // 上传报告到已绑定的万川知识库 const uploadToKnowledgeBaseIfBound = async (reportTitle, content) => { if (uploadingToKb) return setUploadingToKb(true) try { const bound = await getBoundKnowledgeBase() if (!bound) return // 上传接口必须使用 datasetId const datasetId = bound.kbDatasetId || bound.id if (!bound.platformUrl || !datasetId) return const safeTitle = (reportTitle || 'report').replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_').slice(0, 50) const file = new File([blob], `${safeTitle}.md`, { type: 'text/markdown' }) // 上传接口使用 datasetId await uploadToKnowledgeBase(bound.platformUrl, datasetId, file) onToast?.('报告已同步上传到知识库') } catch (e) { console.error('[上传知识库失败]', e?.message || e) onToast?.(`报告生成成功,但上传到知识库失败: ${e.message}`, 'error') } finally { setUploadingToKb(false) } } const selectedInitState = selectedGroup ? initStateByGroup[selectedGroup.id] : null const analyzing = selectedInitState?.status === 'running' return ( <>