Files
get_wechat/chatlab-web/frontend/src/pages/TopicsPage.jsx
yuanzhipeng eecbe4172e feat(api): 将万川平台配置迁移至后端存储
- 移除前端localStorage依赖,改用后端SQLite作为唯一数据源
- 新增getWanchuanConfig和saveWanchuanConfig函数用于配置读写
- 添加getBoundKnowledgeBase函数统一获取绑定知识库信息
- 支持桌面应用端口变化时正确读取配置

refactor(settings): 重构万川平台配置管理逻辑

- 移除localStorage配置存储,改为后端API调用
- 实现配置自动恢复和防抖保存机制
- 添加token过期自动重登功能
- 优化知识库选择和连接状态管理

fix(knowledge): 修复知识库上传异步问题

- 将getBoundKnowledgeBase调用改为await异步处理
- 统一各页面的知识库信息获取方式
- 修正上传接口datasetId使用逻辑

feat(electron): 添加chatlog.exe存在性检查

- 新增ensureChatlogExe函数验证执行文件存在
- 防止杀毒软件误删导致的ENONENT错误
- 提供用户友好的错误提示和解决方案
2026-06-24 10:13:20 +08:00

1192 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden', height: '100%' }}>
<div style={{ width: 220, borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>群聊</span>
<button className="btn btn-ghost" style={{ padding: '2px 8px', fontSize: 12 }} onClick={openAddGroup}>
<Plus size={13} /> 添加
</button>
</div>
{showAddGroup && (
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<div style={{ position: 'relative', marginBottom: 6 }}>
<input
className="filter-input"
style={{ width: '100%', boxSizing: 'border-box' }}
placeholder="搜索群聊名称..."
value={addGroupKeyword}
onChange={(e) => {
setAddGroupKeyword(e.target.value)
setSelectedTalker('')
setShowGroupDropdown(true)
}}
onFocus={() => setShowGroupDropdown(true)}
/>
{showGroupDropdown && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 100,
background: 'var(--bg-surface)',
border: '1px solid var(--border)',
borderRadius: 6,
maxHeight: 220,
overflowY: 'auto',
boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
}}>
{!addGroupKeywordText && (
<div style={{ padding: '8px 10px', fontSize: 12, fontWeight: 600, color: 'var(--text-muted)' }}>最近群聊</div>
)}
{groupSearchLoading && (
<div style={{ padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>正在搜索...</div>
)}
{groupSearchError && (
<div style={{ padding: '8px 10px', fontSize: 12, color: 'var(--danger)' }}>{groupSearchError}</div>
)}
{!groupSearchLoading && !groupSearchError && displayGroupResults.map((room) => (
<div
key={room.id}
onClick={() => {
setSelectedTalker(room.id)
setAddGroupKeyword(room.name || room.id)
setShowGroupDropdown(false)
}}
style={{
padding: '8px 10px',
cursor: 'pointer',
fontSize: 12,
background: selectedTalker === room.id ? 'var(--bg-overlay)' : 'var(--bg-surface)',
borderBottom: '1px solid var(--border)',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = selectedTalker === room.id ? 'var(--bg-overlay)' : 'var(--bg-surface)' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{room.name || room.id}</div>
{!addGroupKeywordText && room.lastTime > 0 && (
<div style={{ color: 'var(--text-muted)', fontSize: 10, whiteSpace: 'nowrap', flexShrink: 0 }}>
{dayjs.unix(room.lastTime).fromNow()}
</div>
)}
</div>
<div style={{ marginTop: 2, color: 'var(--text-muted)', fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{room.id}</div>
{!addGroupKeywordText && room.lastContent && (
<div style={{ marginTop: 3, color: 'var(--text-muted)', fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{compactText(room.lastContent, 32)}
</div>
)}
</div>
))}
{addGroupKeywordText && !groupSearchLoading && !groupSearchError && groupSearchResults.length === 0 && (
<div style={{ padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>没有找到匹配的群聊</div>
)}
{!addGroupKeywordText && !groupSearchLoading && !groupSearchError && recentGroupResults.length === 0 && (
<div style={{ padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>暂无可添加的最近群聊</div>
)}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-primary" style={{ flex: 1, fontSize: 12 }} disabled={!selectedTalker || addingGroup} onClick={handleAddGroup}>
{addingGroup ? '添加中...' : '确定'}
</button>
<button className="btn btn-ghost" style={{ fontSize: 12 }} disabled={addingGroup} onClick={closeAddGroup}>取消</button>
</div>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto' }}>
{groups.length === 0 && (
<div style={{ padding: 20, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
暂无监控群聊
</div>
)}
{groups.map((group) => (
<div
key={group.id}
onClick={() => handleSelectGroup(group)}
onMouseEnter={() => setHoveredGroupId(group.id)}
onMouseLeave={() => setHoveredGroupId(null)}
style={{
padding: '10px 14px',
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
background: selectedGroup?.id === group.id ? 'var(--surface-2)' : 'transparent',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{group.name || group.talker}
</div>
</div>
{hoveredGroupId === group.id ? (
<button
className="btn btn-ghost"
onClick={(e) => handleDeleteGroup(e, group)}
disabled={deletingGroupId === group.id}
title="删除群聊"
style={{ color: 'var(--danger)', padding: '2px 4px', borderRadius: 4, flexShrink: 0 }}
>
{deletingGroupId === group.id ? <Loader size={13} /> : <Trash2 size={13} />}
</button>
) : (
<ChevronRight size={13} color="var(--text-muted)" />
)}
</div>
))}
</div>
</div>
<div style={{ width: 260, borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{!selectedGroup ? (
<div className="empty-state" style={{ flex: 1 }}>
<div className="empty-state-icon">AI</div>
<div className="empty-state-title">请选择群聊</div>
</div>
) : (
<>
<div style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 8 }}>
<div style={{ fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selectedGroup.name || selectedGroup.talker}
</div>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 6px', flexShrink: 0 }}
onClick={openGroupPrompt}
title="AI 分析提示词"
>
<Settings2 size={12} />
</button>
</div>
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>分析所选时间段全部消息</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
{ANALYZE_PRESETS.map((preset) => (
<div
key={preset.label}
className={`chip ${analyzePreset === preset.label ? 'active' : ''}`}
style={{ fontSize: 11, padding: '3px 8px' }}
onClick={() => applyAnalyzePreset(preset)}
>
{preset.label}
</div>
))}
</div>
<div style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 11 }}>
<input
type="datetime-local"
className="filter-input"
value={analyzeStartDate}
onChange={(e) => { setAnalyzeStartDate(e.target.value); setAnalyzePreset('') }}
style={{ fontSize: 11, flex: 1, minWidth: 0 }}
/>
<span style={{ color: 'var(--text-muted)' }}></span>
<input
type="datetime-local"
className="filter-input"
value={analyzeEndDate}
onChange={(e) => { setAnalyzeEndDate(e.target.value); setAnalyzePreset('') }}
style={{ fontSize: 11, flex: 1, minWidth: 0 }}
/>
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button
className="btn btn-primary"
style={{ flex: 1, fontSize: 12 }}
onClick={() => handleInitGroup(selectedGroup)}
disabled={analyzing}
>
{analyzing ? <><Loader size={12} /> 分析中...</> : <><Sparkles size={12} /> AI 分析</>}
</button>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => loadTopics(selectedGroup.id)}>
<RefreshCw size={12} />
</button>
</div>
{analyzing && (
<div style={{ marginTop: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>
<span>分析中...</span>
<span>
{selectedInitState.progress
? `${selectedInitState.progress.processed ?? 0} / ${selectedInitState.progress.total > 0 ? selectedInitState.progress.total : '?'}`
: '准备中...'}
</span>
</div>
<div style={{ height: 4, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{
height: '100%',
width: selectedInitState.progress && selectedInitState.progress.total > 0
? `${Math.min(100, (selectedInitState.progress.processed / selectedInitState.progress.total) * 100)}%`
: '0%',
background: 'var(--accent)',
borderRadius: 2,
transition: 'width 0.5s ease',
}} />
</div>
</div>
)}
</div>
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 6 }}>
<input
className="filter-input"
style={{ flex: 1, fontSize: 12 }}
placeholder="新建手动话题..."
value={newTopicTitle}
onChange={(e) => setNewTopicTitle(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreateTopic()}
/>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={handleCreateTopic}>
<Plus size={13} />
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{loading && (
<div style={{ padding: 20, textAlign: 'center', fontSize: 12, color: 'var(--text-muted)' }}>加载中...</div>
)}
{!loading && topics.length === 0 && (
<div style={{ padding: 20, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
暂无话题
</div>
)}
{topics.map((topic) => (
<div
key={topic.id}
onClick={() => handleSelectTopic(topic)}
style={{
padding: '10px 14px',
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
background: selectedTopic?.id === topic.id ? 'var(--surface-2)' : 'transparent',
display: 'flex',
alignItems: 'flex-start',
gap: 8,
}}
>
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{topic.title}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>
{dayjs(topic.created_at).format('MM-DD HH:mm')} / {topic.status}
</div>
</div>
<button
className="btn btn-ghost"
style={{ padding: '2px 4px', opacity: 0.5 }}
onClick={(e) => { e.stopPropagation(); handleDeleteTopic(topic) }}
>
<Trash2 size={12} />
</button>
</div>
))}
</div>
</>
)}
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{!selectedTopic ? (
<div className="empty-state" style={{ flex: 1 }}>
<div className="empty-state-icon">话题</div>
<div className="empty-state-title">请选择话题</div>
</div>
) : (
<>
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ fontWeight: 600, fontSize: 15 }}>{selectedTopic.title}</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-ghost" style={{ borderColor: 'rgba(99,102,241,0.4)', color: 'var(--accent-light)' }} onClick={handleOpenMsgManager}>
<Settings2 size={13} /> 管理消息
</button>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
<button
className="btn btn-ghost"
style={{ borderColor: 'rgba(99,102,241,0.4)', color: 'var(--accent-light)' }}
onClick={handleSummarize}
disabled={summarizing}
>
{summarizing ? <Loader size={13} /> : <Sparkles size={13} />}
{summarizing ? '生成中...' : 'AI 报告'}
</button>
{summarizing && (
<div style={{ width: 180 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: 'var(--text-muted)', marginBottom: 3 }}>
<span>处理中...</span>
<span>{summarizeProgress ? `${summarizeProgress.processed ?? 0}/${summarizeProgress.total > 0 ? summarizeProgress.total : '?'}` : '准备中...'}</span>
</div>
<div style={{ height: 3, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{
height: '100%',
width: summarizeProgress && summarizeProgress.total > 0
? `${Math.min(100, (summarizeProgress.processed / summarizeProgress.total) * 100)}%`
: '30%',
background: 'var(--accent)',
borderRadius: 2,
transition: 'width 0.5s ease',
}} />
</div>
</div>
)}
</div>
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px' }}>
{topicDetail?.knowledge_doc && (
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 10 }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 600 }}>报告</div>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={handleExportReport}>
<Download size={13} /> 导出 Word
</button>
</div>
<ReportDocumentView content={topicDetail.knowledge_doc.content} />
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 6 }}>
更新于 {dayjs(topicDetail.knowledge_doc.updated_at).format('YYYY-MM-DD HH:mm')}
</div>
</div>
)}
{topicDetail && (
<div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8, fontWeight: 600 }}>
关联消息{topicDetail.messages?.length ?? 0}
</div>
{(topicDetail.messages || []).length === 0 && (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>该话题暂无消息</div>
)}
{(topicDetail.messages || []).map((message, index) => (
<MessageBubble key={message.seq || index} msg={toMsgBubbleFormat(message, index)} />
))}
</div>
)}
{!topicDetail && (
<div style={{ textAlign: 'center', padding: 20, color: 'var(--text-muted)', fontSize: 13 }}>加载中...</div>
)}
</div>
</>
)}
</div>
</div>
{showMsgManager && selectedTopic && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: '#ffffff', borderRadius: 12, width: 900, maxWidth: '95vw', height: '80vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}>
<div style={{ padding: '14px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<span style={{ fontWeight: 600, fontSize: 15 }}>{selectedTopic.title} / 消息管理</span>
<button onClick={() => setShowMsgManager(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}>
<X size={18} />
</button>
</div>
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border)', flexShrink: 0, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<input type="datetime-local" className="filter-input" value={msgStartDate} onChange={(e) => setMsgStartDate(e.target.value)} style={{ fontSize: 12 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
<input type="datetime-local" className="filter-input" value={msgEndDate} onChange={(e) => setMsgEndDate(e.target.value)} style={{ fontSize: 12 }} />
<select className="filter-input" style={{ fontSize: 12 }} value={msgMembers[0] || ''} onChange={(e) => setMsgMembers(e.target.value ? [e.target.value] : [])}>
<option value="">全部成员</option>
{msgAllMembers.map((member) => (
<option key={member.platformId} value={member.platformId}>{member.accountName}</option>
))}
</select>
<input className="filter-input" style={{ fontSize: 12, flex: 1, minWidth: 120 }} placeholder="搜索消息..." value={msgSearch} onChange={(e) => setMsgSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && fetchMsgs()} />
<button className="btn btn-primary btn-sm" onClick={fetchMsgs} style={{ fontSize: 12 }}>搜索</button>
</div>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
<div style={{ flex: 1, borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '8px 14px', fontSize: 12, color: 'var(--text-muted)', fontWeight: 600, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
聊天记录消息
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{msgLoading && <div style={{ padding: 20, textAlign: 'center', color: 'var(--text-muted)', fontSize: 13 }}>加载中...</div>}
{!msgLoading && (() => {
const topicSeqs = new Set((topicDetail?.messages || []).map((m) => Number(m.seq)))
return allMsgs.map((message) => (
<div key={message.id} style={{ display: 'flex', alignItems: 'flex-start', borderBottom: '1px solid var(--border)' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<MessageBubble msg={message} />
</div>
<div style={{ flexShrink: 0, padding: '12px 10px' }}>
{topicSeqs.has(Number(message.id)) ? (
<button
className="btn btn-ghost btn-sm"
style={{ fontSize: 12, color: 'var(--danger)', borderColor: 'rgba(243,139,168,0.4)' }}
title="从话题中移除"
onClick={() => handleRemoveMsg(Number(message.id))}
>
移除
</button>
) : (
<button className="btn btn-ghost btn-sm" style={{ fontSize: 12 }} onClick={() => handleAddMsg(message)}>添加</button>
)}
</div>
</div>
))
})()}
</div>
</div>
<div style={{ width: 340, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '8px 14px', fontSize: 12, color: 'var(--text-muted)', fontWeight: 600, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
话题消息
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
{(topicDetail?.messages || []).length === 0 && (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text-muted)', fontSize: 13 }}>暂无已选消息</div>
)}
{(topicDetail?.messages || []).map((message) => (
<div key={message.seq} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '6px 14px', borderBottom: '1px solid var(--border)' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 }}>{message.sender_name || message.sender}</div>
<div style={{ fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{topicMsgPreview(message)}
</div>
</div>
<button className="btn btn-ghost btn-sm" style={{ flexShrink: 0, fontSize: 12, color: 'var(--danger)' }} onClick={() => handleRemoveMsg(message.seq)}>移除</button>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)}
{showGroupPrompt && selectedGroup && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: '#fff', borderRadius: 10, width: 620, maxWidth: '92vw', boxShadow: '0 8px 32px rgba(0,0,0,0.2)', overflow: 'hidden' }}>
<div style={{ padding: '14px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>AI 分析提示词</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{selectedGroup.name || selectedGroup.talker}</div>
</div>
<button onClick={() => setShowGroupPrompt(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}>
<X size={18} />
</button>
</div>
<div style={{ padding: 18 }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
留空则使用设置页的全局默认提示词填写后只对当前群生效
</div>
<textarea
value={groupPromptDraft}
placeholder="例如:这个群主要分析某企业的设备售后问题,请按设备型号、故障现象、处理进度归类;不要把日常闲聊单独生成售后话题。"
onChange={(e) => setGroupPromptDraft(e.target.value)}
style={{
width: '100%',
minHeight: 180,
boxSizing: 'border-box',
border: '1px solid var(--border)',
borderRadius: 8,
padding: '10px 12px',
fontSize: 13,
lineHeight: 1.6,
resize: 'vertical',
outline: 'none',
fontFamily: 'inherit',
}}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 14 }}>
<button className="btn btn-ghost" onClick={() => setShowGroupPrompt(false)} disabled={savingGroupPrompt}>取消</button>
<button className="btn btn-primary" onClick={handleSaveGroupPrompt} disabled={savingGroupPrompt}>
{savingGroupPrompt ? <Loader size={13} /> : <Settings2 size={13} />}
{savingGroupPrompt ? '保存中...' : '保存提示词'}
</button>
</div>
</div>
</div>
</div>
)}
</>
)
}