feat(history): 实现三模历史搜索、每日智能协同摘要与时间轴侧栏 (Phase P2)

This commit is contained in:
WeiCHEN
2026-09-13 02:50:20 +00:00
committed by weichen
parent 54f1c88791
commit 487dc072f5
19 changed files with 2396 additions and 29 deletions
@@ -9,6 +9,8 @@ import {
Calendar,
Layers,
Info,
Search,
Sparkles,
} from 'lucide-react';
import { MessageCategory, ChatCategoryStat } from '../../types/workbench';
@@ -18,6 +20,8 @@ interface CategoryFilterBarProps {
onSelectCategory: (cat: MessageCategory | 'all') => void;
onToggleCalendar?: () => void;
isCalendarOpen?: boolean;
onOpenSearch?: () => void;
onOpenDigest?: () => void;
}
const CATEGORY_META: Record<
@@ -40,6 +44,8 @@ export const CategoryFilterBar: React.FC<CategoryFilterBarProps> = ({
onSelectCategory,
onToggleCalendar,
isCalendarOpen = false,
onOpenSearch,
onOpenDigest,
}) => {
const totalCount = categories.reduce((sum, c) => sum + c.count, 0);
@@ -155,34 +161,84 @@ export const CategoryFilterBar: React.FC<CategoryFilterBarProps> = ({
})}
</div>
{/* 右侧日历入口 */}
{onToggleCalendar && (
<button
onClick={onToggleCalendar}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '5px',
padding: '3px 10px',
borderRadius: 'var(--r-pill, 9999px)',
fontSize: '11px',
fontWeight: 600,
background: isCalendarOpen
? 'rgba(79, 107, 255, 0.2)'
: 'var(--bg-subtle, rgba(255, 255, 255, 0.05))',
color: isCalendarOpen ? 'var(--brand-400, #6b84ff)' : 'var(--text-muted, #94a3b8)',
border: isCalendarOpen
? '1px solid var(--brand-500, #4f6bff)'
: '1px solid var(--border-light, rgba(255, 255, 255, 0.08))',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
title="展开历史日历"
>
<Calendar size={13} />
<span>历史日历</span>
</button>
)}
{/* 右侧工具组: 搜索、每日摘要、日历 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0 }}>
{onOpenSearch && (
<button
onClick={onOpenSearch}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '3px 8px',
borderRadius: 'var(--r-pill, 9999px)',
fontSize: '11px',
fontWeight: 500,
background: 'var(--bg-subtle, rgba(255, 255, 255, 0.05))',
color: 'var(--text-muted, #94a3b8)',
border: '1px solid var(--border-light, rgba(255, 255, 255, 0.08))',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
title="搜索历史对话 (支持关键词/模糊/语义)"
>
<Search size={12} />
<span>搜索</span>
</button>
)}
{onOpenDigest && (
<button
onClick={onOpenDigest}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '3px 8px',
borderRadius: 'var(--r-pill, 9999px)',
fontSize: '11px',
fontWeight: 500,
background: 'var(--bg-subtle, rgba(255, 255, 255, 0.05))',
color: 'var(--text-muted, #94a3b8)',
border: '1px solid var(--border-light, rgba(255, 255, 255, 0.08))',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
title="查看每日智能协同摘要"
>
<Sparkles size={12} color="var(--brand-400, #6b84ff)" />
<span>摘要</span>
</button>
)}
{onToggleCalendar && (
<button
onClick={onToggleCalendar}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '3px 9px',
borderRadius: 'var(--r-pill, 9999px)',
fontSize: '11px',
fontWeight: 600,
background: isCalendarOpen
? 'rgba(79, 107, 255, 0.2)'
: 'var(--bg-subtle, rgba(255, 255, 255, 0.05))',
color: isCalendarOpen ? 'var(--brand-400, #6b84ff)' : 'var(--text-muted, #94a3b8)',
border: isCalendarOpen
? '1px solid var(--brand-500, #4f6bff)'
: '1px solid var(--border-light, rgba(255, 255, 255, 0.08))',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
title="展开历史日历"
>
<Calendar size={12} />
<span>日历</span>
</button>
)}
</div>
</div>
);
};
@@ -0,0 +1,351 @@
import React, { useState, useEffect, useRef } from 'react';
import { Search, X, Loader2, Calendar, ArrowRight } from 'lucide-react';
import { ChatSearchHit, ChatSearchMode } from '../../types/workbench';
import { searchChatMessages } from '../../api/chatSearch';
interface ChatSearchModalProps {
sessionId: string;
onSelectHit: (hit: ChatSearchHit) => void;
onClose: () => void;
}
export const ChatSearchModal: React.FC<ChatSearchModalProps> = ({
sessionId,
onSelectHit,
onClose,
}) => {
const [query, setQuery] = useState('');
const [mode, setMode] = useState<ChatSearchMode>('hybrid');
const [hits, setHits] = useState<ChatSearchHit[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// 防抖搜索
useEffect(() => {
const trimmed = query.trim();
if (!trimmed) {
setHits([]);
setIsLoading(false);
setHasSearched(false);
return;
}
setIsLoading(true);
const timer = setTimeout(() => {
searchChatMessages({
sessionId,
q: trimmed,
mode,
limit: 50,
})
.then((res) => {
setHits(res || []);
setHasSearched(true);
})
.catch((err) => {
console.warn('search failed:', err);
setHits([]);
setHasSearched(true);
})
.finally(() => {
setIsLoading(false);
});
}, 280);
return () => clearTimeout(timer);
}, [query, mode, sessionId]);
// 高亮摘录中的关键词
const renderHighlightedExcerpt = (excerpt: string, searchWord: string) => {
if (!searchWord.trim()) return excerpt;
const parts = excerpt.split(new RegExp(`(${escapeRegex(searchWord)})`, 'gi'));
return (
<span>
{parts.map((part, i) =>
part.toLowerCase() === searchWord.toLowerCase() ? (
<mark
key={i}
style={{
background: 'rgba(245, 158, 11, 0.3)',
color: '#fbbf24',
padding: '1px 3px',
borderRadius: '3px',
fontWeight: 600,
}}
>
{part}
</mark>
) : (
<span key={i}>{part}</span>
)
)}
</span>
);
};
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1200,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
paddingTop: '80px',
background: 'rgba(15, 23, 42, 0.65)',
backdropFilter: 'blur(8px)',
}}
onClick={onClose}
>
<div
style={{
width: '680px',
maxWidth: '92vw',
maxHeight: '75vh',
display: 'flex',
flexDirection: 'column',
background: 'var(--bg-card, #1e293b)',
border: '1px solid rgba(255, 255, 255, 0.12)',
borderRadius: 'var(--r-lg, 12px)',
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.5)',
overflow: 'hidden',
}}
onClick={(e) => e.stopPropagation()}
>
{/* 顶部搜索框 */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '14px 18px',
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
}}
>
<Search size={18} color="var(--brand-400, #6b84ff)" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索全历史消息、决策记录与技术参数..."
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
fontSize: '15px',
color: 'var(--text-strong, #f8fafc)',
}}
/>
{isLoading && <Loader2 size={16} className="animate-spin" color="var(--text-muted, #94a3b8)" />}
{query && !isLoading && (
<button
onClick={() => setQuery('')}
style={{
background: 'transparent',
border: 'none',
color: 'var(--text-muted, #94a3b8)',
cursor: 'pointer',
padding: '2px',
}}
>
<X size={16} />
</button>
)}
<button
onClick={onClose}
style={{
padding: '3px 8px',
fontSize: '11px',
color: 'var(--text-muted, #94a3b8)',
background: 'rgba(255, 255, 255, 0.06)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '4px',
cursor: 'pointer',
}}
>
ESC
</button>
</div>
{/* 检索模式切换栏 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 18px',
background: 'rgba(0, 0, 0, 0.2)',
borderBottom: '1px solid rgba(255, 255, 255, 0.05)',
fontSize: '12px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ color: 'var(--text-muted, #94a3b8)' }}>模式:</span>
{(['hybrid', 'keyword', 'fuzzy', 'semantic'] as ChatSearchMode[]).map((m) => {
const active = mode === m;
const labels: Record<ChatSearchMode, string> = {
hybrid: '综合推荐',
keyword: '精确关键词',
fuzzy: '模糊匹配',
semantic: '语义理解',
};
return (
<button
key={m}
onClick={() => setMode(m)}
style={{
padding: '2px 8px',
borderRadius: '4px',
border: 'none',
background: active ? 'var(--brand-500, #4f6bff)' : 'transparent',
color: active ? '#fff' : 'var(--text-muted, #94a3b8)',
cursor: 'pointer',
fontSize: '11px',
fontWeight: active ? 600 : 400,
}}
>
{labels[m]}
</button>
);
})}
</div>
{hasSearched && (
<span style={{ color: 'var(--text-faint, #64748b)', fontSize: '11px' }}>
找到 {hits.length} 条相关记录
</span>
)}
</div>
{/* 搜索结果列表 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '12px 18px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{query.trim() === '' && (
<div
style={{
textAlign: 'center',
padding: '40px 0',
color: 'var(--text-faint, #64748b)',
fontSize: '13px',
}}
>
输入关键词(如“预算”、“GB 9706”、“注册”、“临床”),瞬时穿梭至历史锚点
</div>
)}
{hasSearched && hits.length === 0 && (
<div
style={{
textAlign: 'center',
padding: '40px 0',
color: 'var(--text-muted, #94a3b8)',
fontSize: '13px',
}}
>
未找到匹配“{query}”的历史发言,可尝试切换至“模糊匹配”或“语义理解”模式
</div>
)}
{hits.map((hit) => {
const roleLabel = hit.role === 'user' ? '用户' : '项目助手';
const isUser = hit.role === 'user';
return (
<div
key={hit.message_id}
onClick={() => onSelectHit(hit)}
style={{
padding: '10px 14px',
borderRadius: 'var(--r-md, 8px)',
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.06)',
cursor: 'pointer',
transition: 'all 0.15s ease',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(79, 107, 255, 0.08)';
e.currentTarget.style.borderColor = 'rgba(79, 107, 255, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.03)';
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.06)';
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '11px' }}>
<span
style={{
color: isUser ? '#38bdf8' : 'var(--brand-400, #6b84ff)',
fontWeight: 600,
}}
>
{roleLabel}
</span>
<span style={{ color: 'var(--text-faint, #64748b)' }}>•</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '3px', color: 'var(--text-muted, #94a3b8)' }}>
<Calendar size={11} />
{hit.day_key}
</span>
{hit.category && (
<>
<span style={{ color: 'var(--text-faint, #64748b)' }}>•</span>
<span
style={{
padding: '1px 5px',
borderRadius: '3px',
background: 'rgba(255, 255, 255, 0.06)',
color: 'var(--text-muted, #94a3b8)',
}}
>
{hit.category}
</span>
</>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '11px', color: 'var(--text-faint, #64748b)' }}>
<span>跳转</span>
<ArrowRight size={12} />
</div>
</div>
<div style={{ fontSize: '13px', lineHeight: 1.5, color: 'var(--text-subtle, #cbd5e1)' }}>
{renderHighlightedExcerpt(hit.excerpt, query)}
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -0,0 +1,360 @@
import React, { useState, useEffect } from 'react';
import {
X,
Sparkles,
Calendar,
CheckCircle2,
AlertCircle,
ArrowRight,
RefreshCw,
Loader2,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { DailyDigest } from '../../types/workbench';
import { fetchDailyDigests, generateDailyDigest } from '../../api/chatDigest';
interface DigestTimelineModalProps {
sessionId: string;
onJumpToMessage: (messageId: number) => void;
onClose: () => void;
}
export const DigestTimelineModal: React.FC<DigestTimelineModalProps> = ({
sessionId,
onJumpToMessage,
onClose,
}) => {
const [digests, setDigests] = useState<DailyDigest[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [regeneratingDay, setRegeneratingDay] = useState<string | null>(null);
const [expandedCards, setExpandedCards] = useState<Record<string, boolean>>({});
const loadDigests = () => {
setIsLoading(true);
fetchDailyDigests(sessionId)
.then((res) => {
setDigests(res || []);
})
.catch((err) => {
console.warn('fetchDailyDigests error:', err);
})
.finally(() => {
setIsLoading(false);
});
};
useEffect(() => {
loadDigests();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [sessionId, onClose]);
const toggleExpand = (dayKey: string) => {
setExpandedCards((prev) => ({
...prev,
[dayKey]: !prev[dayKey],
}));
};
const handleRegenerate = async (dayKey: string) => {
setRegeneratingDay(dayKey);
try {
const res = await generateDailyDigest(sessionId, dayKey);
if (res.digest) {
setDigests((prev) => {
const idx = prev.findIndex((d) => d.day_key === dayKey);
if (idx >= 0) {
const next = [...prev];
next[idx] = res.digest;
return next;
}
return [res.digest, ...prev];
});
}
} catch (err) {
console.warn('regenerate failed:', err);
} finally {
setRegeneratingDay(null);
}
};
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1200,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px',
background: 'rgba(15, 23, 42, 0.65)',
backdropFilter: 'blur(8px)',
}}
onClick={onClose}
>
<div
style={{
width: '740px',
maxWidth: '94vw',
maxHeight: '82vh',
display: 'flex',
flexDirection: 'column',
background: 'var(--bg-card, #1e293b)',
border: '1px solid rgba(255, 255, 255, 0.12)',
borderRadius: 'var(--r-lg, 12px)',
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.55)',
overflow: 'hidden',
}}
onClick={(e) => e.stopPropagation()}
>
{/* 顶部标题栏 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Sparkles size={18} color="var(--brand-400, #6b84ff)" />
<h2 style={{ margin: 0, fontSize: '15px', fontWeight: 600, color: 'var(--text-strong, #f8fafc)' }}>
每日协同与工作决策摘要 (Daily Digest)
</h2>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={loadDigests}
title="刷新列表"
style={{
background: 'transparent',
border: 'none',
color: 'var(--text-muted, #94a3b8)',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
}}
>
<RefreshCw size={15} />
</button>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: 'var(--text-muted, #94a3b8)',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
}}
>
<X size={18} />
</button>
</div>
</div>
{/* 主体摘要时间轴列表 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '18px 20px',
display: 'flex',
flexDirection: 'column',
gap: '14px',
}}
>
{isLoading && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '60px 0', gap: '8px', color: 'var(--text-muted, #94a3b8)' }}>
<Loader2 size={18} className="animate-spin" />
<span>正在拉取每日摘要时间轴...</span>
</div>
)}
{!isLoading && digests.length === 0 && (
<div style={{ textAlign: 'center', padding: '60px 0', color: 'var(--text-muted, #94a3b8)', fontSize: '13px' }}>
当前会话尚未生成每日摘要。对话历史沉淀后,系统将自动汇总每日核心结论与拍板决策。
</div>
)}
{digests.map((digest) => {
const isExpanded = expandedCards[digest.day_key] !== false; // 默认展开
const isRegenerating = regeneratingDay === digest.day_key;
return (
<div
key={digest.day_key}
style={{
borderRadius: 'var(--r-md, 10px)',
background: 'rgba(255, 255, 255, 0.02)',
border: '1px solid rgba(255, 255, 255, 0.08)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* 卡片头部 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
background: 'rgba(255, 255, 255, 0.03)',
borderBottom: isExpanded ? '1px solid rgba(255, 255, 255, 0.06)' : 'none',
cursor: 'pointer',
}}
onClick={() => toggleExpand(digest.day_key)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '13px',
fontWeight: 600,
color: 'var(--brand-400, #6b84ff)',
}}
>
<Calendar size={14} />
<span>{digest.day_key}</span>
</div>
<span
style={{
fontSize: '11px',
padding: '1px 6px',
borderRadius: '4px',
background: digest.source === 'llm' ? 'rgba(79, 107, 255, 0.15)' : 'rgba(255, 255, 255, 0.08)',
color: digest.source === 'llm' ? 'var(--brand-400, #6b84ff)' : 'var(--text-muted, #94a3b8)',
}}
>
{digest.source === 'llm' ? 'AI总结' : '规则沉淀'} • {digest.message_count} 条消息
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={(e) => {
e.stopPropagation();
if (digest.first_message_id) {
onJumpToMessage(digest.first_message_id);
onClose();
}
}}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '3px 9px',
borderRadius: '4px',
border: '1px solid rgba(79, 107, 255, 0.4)',
background: 'rgba(79, 107, 255, 0.1)',
color: 'var(--brand-400, #6b84ff)',
fontSize: '11px',
fontWeight: 500,
cursor: 'pointer',
}}
>
<span>定位当日首条</span>
<ArrowRight size={11} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleRegenerate(digest.day_key);
}}
disabled={isRegenerating}
title="重新生成摘要"
style={{
background: 'transparent',
border: 'none',
color: 'var(--text-faint, #64748b)',
cursor: 'pointer',
padding: '3px',
}}
>
{isRegenerating ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
</button>
{isExpanded ? <ChevronUp size={15} color="var(--text-muted, #94a3b8)" /> : <ChevronDown size={15} color="var(--text-muted, #94a3b8)" />}
</div>
</div>
{/* 卡片详情 */}
{isExpanded && (
<div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
{/* 核心总结 Headline */}
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong, #f8fafc)', lineHeight: 1.5 }}>
{digest.headline}
</div>
{/* 工作要点 */}
{digest.key_points && digest.key_points.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted, #94a3b8)' }}>
📌 核心要点
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px', paddingLeft: '4px' }}>
{digest.key_points.map((pt, i) => (
<div key={i} style={{ fontSize: '12px', color: 'var(--text-subtle, #cbd5e1)', lineHeight: 1.4 }}>
• {pt}
</div>
))}
</div>
</div>
)}
{/* 拍板决策 */}
{digest.decisions && digest.decisions.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '11px', fontWeight: 600, color: '#10b981' }}>
<CheckCircle2 size={12} />
<span>关键决策与里程碑</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px', paddingLeft: '4px' }}>
{digest.decisions.map((dec, i) => (
<div key={i} style={{ fontSize: '12px', color: '#a7f3d0', lineHeight: 1.4 }}>
✓ {dec}
</div>
))}
</div>
</div>
)}
{/* 未决事项 */}
{digest.open_questions && digest.open_questions.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '11px', fontWeight: 600, color: '#f59e0b' }}>
<AlertCircle size={12} />
<span>未决问题与跟进</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px', paddingLeft: '4px' }}>
{digest.open_questions.map((q, i) => (
<div key={i} style={{ fontSize: '12px', color: '#fde68a', lineHeight: 1.4 }}>
? {q}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
};