update:1.更新导入导出功能 2.实现RAG记忆功能,引入剧情分析功能

This commit is contained in:
xiamuceer
2025-11-04 14:38:59 +08:00
parent 1cde345ed9
commit e4f90d5da0
26 changed files with 6722 additions and 84 deletions
+253
View File
@@ -0,0 +1,253 @@
import React, { useMemo, useEffect, useRef } from 'react';
import { Tooltip } from 'antd';
// 标注数据类型
export interface MemoryAnnotation {
id: string;
type: 'hook' | 'foreshadow' | 'plot_point' | 'character_event';
title: string;
content: string;
importance: number;
position: number;
length: number;
tags: string[];
metadata: {
strength?: number;
foreshadowType?: 'planted' | 'resolved';
relatedCharacters?: string[];
[key: string]: any;
};
}
// 文本片段类型
interface TextSegment {
type: 'text' | 'annotated';
content: string;
annotation?: MemoryAnnotation;
}
interface AnnotatedTextProps {
content: string;
annotations: MemoryAnnotation[];
onAnnotationClick?: (annotation: MemoryAnnotation) => void;
activeAnnotationId?: string;
scrollToAnnotation?: string;
}
// 类型颜色映射
const TYPE_COLORS = {
hook: '#ff6b6b',
foreshadow: '#6b7bff',
plot_point: '#51cf66',
character_event: '#ffd93d',
};
// 类型图标映射
const TYPE_ICONS = {
hook: '🎣',
foreshadow: '🌟',
plot_point: '💎',
character_event: '👤',
};
/**
* 带标注的文本组件
* 将记忆标注可视化地展示在章节文本中
*/
const AnnotatedText: React.FC<AnnotatedTextProps> = ({
content,
annotations,
onAnnotationClick,
activeAnnotationId,
scrollToAnnotation,
}) => {
const annotationRefs = useRef<Record<string, HTMLSpanElement | null>>({});
// 当需要滚动到特定标注时
useEffect(() => {
if (scrollToAnnotation && annotationRefs.current[scrollToAnnotation]) {
const element = annotationRefs.current[scrollToAnnotation];
element?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, [scrollToAnnotation]);
// 处理标注重叠和排序
const processedAnnotations = useMemo(() => {
if (!annotations || annotations.length === 0) {
console.log('AnnotatedText: 没有标注数据');
return [];
}
console.log(`AnnotatedText: 收到${annotations.length}个标注,内容长度${content.length}`);
// 过滤掉无效位置的标注
const validAnnotations = annotations.filter(
(a) => a.position >= 0 && a.position < content.length
);
const invalidCount = annotations.length - validAnnotations.length;
if (invalidCount > 0) {
console.warn(`AnnotatedText: ${invalidCount}个标注位置无效,有效标注${validAnnotations.length}`);
console.log('无效标注:', annotations.filter(a => a.position < 0 || a.position >= content.length));
}
// 按位置排序
return validAnnotations.sort((a, b) => a.position - b.position);
}, [annotations, content]);
// 将文本分割为带标注的片段
const segments = useMemo(() => {
if (processedAnnotations.length === 0) {
return [{ type: 'text' as const, content }];
}
const result: TextSegment[] = [];
let lastPos = 0;
for (const annotation of processedAnnotations) {
const { position, length } = annotation;
// 添加普通文本片段
if (position > lastPos) {
result.push({
type: 'text',
content: content.slice(lastPos, position),
});
}
// 添加标注片段
const annotatedContent = content.slice(
position,
position + (length > 0 ? length : 30) // 如果没有长度,默认30字符
);
result.push({
type: 'annotated',
content: annotatedContent,
annotation,
});
lastPos = position + (length > 0 ? length : 30);
}
// 添加剩余文本
if (lastPos < content.length) {
result.push({
type: 'text',
content: content.slice(lastPos),
});
}
return result;
}, [content, processedAnnotations]);
// 渲染标注片段
const renderAnnotatedSegment = (segment: TextSegment, index: number) => {
if (segment.type === 'text') {
return <span key={index}>{segment.content}</span>;
}
const { annotation } = segment;
if (!annotation) return null;
const color = TYPE_COLORS[annotation.type];
const icon = TYPE_ICONS[annotation.type];
const isActive = activeAnnotationId === annotation.id;
// 工具提示内容
const tooltipContent = (
<div style={{ maxWidth: 300 }}>
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>
{icon} {annotation.title}
</div>
<div style={{ fontSize: 12, opacity: 0.9 }}>
{annotation.content.slice(0, 100)}
{annotation.content.length > 100 ? '...' : ''}
</div>
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.7 }}>
: {(annotation.importance * 10).toFixed(1)}/10
</div>
{annotation.tags && annotation.tags.length > 0 && (
<div style={{ marginTop: 4, fontSize: 11 }}>
{annotation.tags.map((tag, i) => (
<span
key={i}
style={{
display: 'inline-block',
background: 'rgba(255,255,255,0.2)',
padding: '2px 6px',
borderRadius: 3,
marginRight: 4,
}}
>
{tag}
</span>
))}
</div>
)}
</div>
);
return (
<Tooltip key={index} title={tooltipContent} placement="top">
<span
ref={(el) => {
if (annotation) {
annotationRefs.current[annotation.id] = el;
}
}}
data-annotation-id={annotation?.id}
className={`annotated-text ${isActive ? 'active' : ''}`}
style={{
position: 'relative',
borderBottom: `2px solid ${color}`,
cursor: 'pointer',
backgroundColor: isActive ? `${color}22` : 'transparent',
transition: 'all 0.2s',
padding: '2px 0',
}}
onClick={() => onAnnotationClick?.(annotation)}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = `${color}33`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = isActive
? `${color}22`
: 'transparent';
}}
>
{segment.content}
<span
style={{
position: 'absolute',
top: -20,
left: '50%',
transform: 'translateX(-50%)',
fontSize: 14,
pointerEvents: 'none',
}}
>
{icon}
</span>
</span>
</Tooltip>
);
};
return (
<div
style={{
lineHeight: 2,
fontSize: 16,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{segments.map((segment, index) => renderAnnotatedSegment(segment, index))}
</div>
);
};
export default AnnotatedText;
+533
View File
@@ -0,0 +1,533 @@
import { useState, useEffect } from 'react';
import { Modal, Progress, Spin, Alert, Tabs, Card, Tag, List, Empty, Statistic, Row, Col, Button } from 'antd';
import {
ThunderboltOutlined,
BulbOutlined,
FireOutlined,
HeartOutlined,
TeamOutlined,
TrophyOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
ReloadOutlined
} from '@ant-design/icons';
import type { AnalysisTask, ChapterAnalysisResponse } from '../types';
interface ChapterAnalysisProps {
chapterId: string;
visible: boolean;
onClose: () => void;
}
export default function ChapterAnalysis({ chapterId, visible, onClose }: ChapterAnalysisProps) {
const [task, setTask] = useState<AnalysisTask | null>(null);
const [analysis, setAnalysis] = useState<ChapterAnalysisResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (visible && chapterId) {
fetchAnalysisStatus();
}
}, [visible, chapterId]);
const fetchAnalysisStatus = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/chapters/${chapterId}/analysis/status`);
if (response.status === 404) {
setTask(null);
setError('该章节还未进行分析');
return;
}
if (!response.ok) {
throw new Error('获取分析状态失败');
}
const taskData: AnalysisTask = await response.json();
setTask(taskData);
if (taskData.status === 'completed') {
await fetchAnalysisResult();
} else if (taskData.status === 'running' || taskData.status === 'pending') {
// 开始轮询
startPolling();
}
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const fetchAnalysisResult = async () => {
try {
const response = await fetch(`/api/chapters/${chapterId}/analysis`);
if (!response.ok) {
throw new Error('获取分析结果失败');
}
const data: ChapterAnalysisResponse = await response.json();
setAnalysis(data);
} catch (err) {
setError((err as Error).message);
}
};
const startPolling = () => {
const pollInterval = setInterval(async () => {
try {
const response = await fetch(`/api/chapters/${chapterId}/analysis/status`);
if (!response.ok) return;
const taskData: AnalysisTask = await response.json();
setTask(taskData);
if (taskData.status === 'completed') {
clearInterval(pollInterval);
await fetchAnalysisResult();
} else if (taskData.status === 'failed') {
clearInterval(pollInterval);
setError(taskData.error_message || '分析失败');
}
} catch (err) {
console.error('轮询错误:', err);
}
}, 2000);
// 5分钟超时
setTimeout(() => clearInterval(pollInterval), 300000);
};
const triggerAnalysis = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/chapters/${chapterId}/analyze`, {
method: 'POST'
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || '触发分析失败');
}
const result = await response.json();
setTask({
task_id: result.task_id,
chapter_id: chapterId,
status: 'pending',
progress: 0
});
// 开始轮询
startPolling();
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const renderStatusIcon = () => {
if (!task) return null;
switch (task.status) {
case 'pending':
return <ClockCircleOutlined style={{ color: '#faad14' }} />;
case 'running':
return <Spin />;
case 'completed':
return <CheckCircleOutlined style={{ color: '#52c41a' }} />;
case 'failed':
return <CloseCircleOutlined style={{ color: '#ff4d4f' }} />;
default:
return null;
}
};
const renderProgress = () => {
if (!task || task.status === 'completed') return null;
return (
<div style={{ padding: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
{renderStatusIcon()}
<span style={{ marginLeft: 8, fontSize: 16 }}>
{task.status === 'pending' && '等待分析...'}
{task.status === 'running' && 'AI正在分析中...'}
{task.status === 'failed' && '分析失败'}
</span>
</div>
<Progress
percent={task.progress}
status={task.status === 'failed' ? 'exception' : 'active'}
/>
{task.status === 'failed' && task.error_message && (
<Alert
message="分析失败"
description={task.error_message}
type="error"
showIcon
style={{ marginTop: 16 }}
/>
)}
</div>
);
};
const renderAnalysisResult = () => {
if (!analysis) return null;
const { analysis: analysis_data, memories } = analysis;
return (
<Tabs
defaultActiveKey="overview"
style={{ height: '100%' }}
items={[
{
key: 'overview',
label: '概览',
icon: <TrophyOutlined />,
children: (
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card title="整体评分" style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={6}>
<Statistic
title="整体质量"
value={analysis_data.overall_quality_score || 0}
suffix="/ 10"
valueStyle={{ color: '#3f8600' }}
/>
</Col>
<Col span={6}>
<Statistic
title="节奏把控"
value={analysis_data.pacing_score || 0}
suffix="/ 10"
/>
</Col>
<Col span={6}>
<Statistic
title="吸引力"
value={analysis_data.engagement_score || 0}
suffix="/ 10"
/>
</Col>
<Col span={6}>
<Statistic
title="连贯性"
value={analysis_data.coherence_score || 0}
suffix="/ 10"
/>
</Col>
</Row>
</Card>
{analysis_data.analysis_report && (
<Card title="分析摘要" style={{ marginBottom: 16 }}>
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit' }}>
{analysis_data.analysis_report}
</pre>
</Card>
)}
{analysis_data.suggestions && analysis_data.suggestions.length > 0 && (
<Card title={<><BulbOutlined /> </>}>
<List
dataSource={analysis_data.suggestions}
renderItem={(item, index) => (
<List.Item>
<span>{index + 1}. {item}</span>
</List.Item>
)}
/>
</Card>
)}
</div>
)
},
{
key: 'hooks',
label: `钩子 (${analysis_data.hooks?.length || 0})`,
icon: <ThunderboltOutlined />,
children: (
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card>
{analysis_data.hooks && analysis_data.hooks.length > 0 ? (
<List
dataSource={analysis_data.hooks}
renderItem={(hook) => (
<List.Item>
<List.Item.Meta
title={
<div>
<Tag color="blue">{hook.type}</Tag>
<Tag color="orange">{hook.position}</Tag>
<Tag color="red">: {hook.strength}/10</Tag>
</div>
}
description={hook.content}
/>
</List.Item>
)}
/>
) : (
<Empty description="暂无钩子" />
)}
</Card>
</div>
)
},
{
key: 'foreshadows',
label: `伏笔 (${analysis_data.foreshadows?.length || 0})`,
icon: <FireOutlined />,
children: (
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card>
{analysis_data.foreshadows && analysis_data.foreshadows.length > 0 ? (
<List
dataSource={analysis_data.foreshadows}
renderItem={(foreshadow) => (
<List.Item>
<List.Item.Meta
title={
<div>
<Tag color={foreshadow.type === 'planted' ? 'green' : 'purple'}>
{foreshadow.type === 'planted' ? '已埋下' : '已回收'}
</Tag>
<Tag>: {foreshadow.strength}/10</Tag>
<Tag>: {foreshadow.subtlety}/10</Tag>
{foreshadow.reference_chapter && (
<Tag color="cyan">{foreshadow.reference_chapter}</Tag>
)}
</div>
}
description={foreshadow.content}
/>
</List.Item>
)}
/>
) : (
<Empty description="暂无伏笔" />
)}
</Card>
</div>
)
},
{
key: 'emotion',
label: '情感曲线',
icon: <HeartOutlined />,
children: (
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card>
{analysis_data.emotional_tone ? (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={12}>
<Statistic
title="主导情绪"
value={analysis_data.emotional_tone}
/>
</Col>
<Col span={12}>
<Statistic
title="情感强度"
value={(analysis_data.emotional_intensity * 10).toFixed(1)}
suffix="/ 10"
/>
</Col>
</Row>
<Card type="inner" title="剧情阶段" size="small">
<p><strong></strong>{analysis_data.plot_stage}</p>
<p><strong></strong>{analysis_data.conflict_level} / 10</p>
{analysis_data.conflict_types && analysis_data.conflict_types.length > 0 && (
<div style={{ marginTop: 8 }}>
<strong></strong>
{analysis_data.conflict_types.map((type, idx) => (
<Tag key={idx} color="red" style={{ margin: 4 }}>
{type}
</Tag>
))}
</div>
)}
</Card>
</div>
) : (
<Empty description="暂无情感分析" />
)}
</Card>
</div>
)
},
{
key: 'characters',
label: `角色 (${analysis_data.character_states?.length || 0})`,
icon: <TeamOutlined />,
children: (
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card>
{analysis_data.character_states && analysis_data.character_states.length > 0 ? (
<List
dataSource={analysis_data.character_states}
renderItem={(char) => (
<List.Item>
<Card
type="inner"
title={char.character_name}
size="small"
style={{ width: '100%' }}
>
<p><strong></strong>{char.state_before} {char.state_after}</p>
<p><strong></strong>{char.psychological_change}</p>
<p><strong></strong>{char.key_event}</p>
{char.relationship_changes && Object.keys(char.relationship_changes).length > 0 && (
<div>
<strong></strong>
{Object.entries(char.relationship_changes).map(([name, change]) => (
<Tag key={name} color="blue" style={{ margin: 4 }}>
{name}: {change}
</Tag>
))}
</div>
)}
</Card>
</List.Item>
)}
/>
) : (
<Empty description="暂无角色分析" />
)}
</Card>
</div>
)
},
{
key: 'memories',
label: `记忆 (${memories?.length || 0})`,
icon: <FireOutlined />,
children: (
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
<Card>
{memories && memories.length > 0 ? (
<List
dataSource={memories}
renderItem={(memory) => (
<List.Item>
<List.Item.Meta
title={
<div>
<Tag color="blue">{memory.type}</Tag>
<Tag color="orange">: {memory.importance.toFixed(1)}</Tag>
{memory.is_foreshadow === 1 && <Tag color="green"></Tag>}
{memory.is_foreshadow === 2 && <Tag color="purple"></Tag>}
<span style={{ marginLeft: 8 }}>{memory.title}</span>
</div>
}
description={
<div>
<p>{memory.content}</p>
<div>
{memory.tags.map((tag, idx) => (
<Tag key={idx} style={{ margin: 2 }}>{tag}</Tag>
))}
</div>
</div>
}
/>
</List.Item>
)}
/>
) : (
<Empty description="暂无记忆片段" />
)}
</Card>
</div>
)
}
]}
/>
);
};
return (
<Modal
title="章节分析"
open={visible}
onCancel={onClose}
width="90%"
centered
style={{
maxWidth: '1400px',
paddingBottom: 0
}}
styles={{
body: {
padding: '24px',
paddingBottom: 0
}
}}
footer={[
<Button key="close" onClick={onClose}>
</Button>,
!task && (
<Button
key="analyze"
type="primary"
icon={<ReloadOutlined />}
onClick={triggerAnalysis}
loading={loading}
>
</Button>
),
task && (task.status === 'failed' || task.status === 'completed') && (
<Button
key="reanalyze"
type="primary"
icon={<ReloadOutlined />}
onClick={triggerAnalysis}
loading={loading}
danger={task.status === 'failed'}
>
</Button>
)
]}
>
{loading && !task && (
<div style={{ textAlign: 'center', padding: '48px' }}>
<Spin size="large" />
<p style={{ marginTop: 16 }}>...</p>
</div>
)}
{error && (
<Alert
message="错误"
description={error}
type="error"
showIcon
action={
<Button size="small" danger onClick={triggerAnalysis}>
</Button>
}
/>
)}
{task && task.status !== 'completed' && renderProgress()}
{task && task.status === 'completed' && analysis && renderAnalysisResult()}
</Modal>
);
}
+250
View File
@@ -0,0 +1,250 @@
import React, { useMemo, useEffect, useRef } from 'react';
import { Card, Tag, Badge, Empty, Collapse, Divider } from 'antd';
import {
FireOutlined,
StarOutlined,
ThunderboltOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { MemoryAnnotation } from './AnnotatedText';
const { Panel } = Collapse;
interface MemorySidebarProps {
annotations: MemoryAnnotation[];
activeAnnotationId?: string;
onAnnotationClick?: (annotation: MemoryAnnotation) => void;
scrollToAnnotation?: string;
}
// 类型配置
const TYPE_CONFIG = {
hook: {
label: '钩子',
icon: <FireOutlined />,
color: '#ff6b6b',
},
foreshadow: {
label: '伏笔',
icon: <StarOutlined />,
color: '#6b7bff',
},
plot_point: {
label: '情节点',
icon: <ThunderboltOutlined />,
color: '#51cf66',
},
character_event: {
label: '角色事件',
icon: <UserOutlined />,
color: '#ffd93d',
},
};
/**
* 记忆侧边栏组件
* 展示章节的所有记忆标注
*/
const MemorySidebar: React.FC<MemorySidebarProps> = ({
annotations,
activeAnnotationId,
onAnnotationClick,
scrollToAnnotation,
}) => {
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
// 当需要滚动到特定标注卡片时
useEffect(() => {
if (scrollToAnnotation && cardRefs.current[scrollToAnnotation]) {
const element = cardRefs.current[scrollToAnnotation];
element?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, [scrollToAnnotation]);
// 按类型分组
const groupedAnnotations = useMemo(() => {
const groups: Record<string, MemoryAnnotation[]> = {
hook: [],
foreshadow: [],
plot_point: [],
character_event: [],
};
annotations.forEach((annotation) => {
if (groups[annotation.type]) {
groups[annotation.type].push(annotation);
}
});
// 每组按重要性排序
Object.keys(groups).forEach((type) => {
groups[type].sort((a, b) => b.importance - a.importance);
});
return groups;
}, [annotations]);
// 统计信息
const stats = useMemo(() => {
return {
total: annotations.length,
hooks: groupedAnnotations.hook.length,
foreshadows: groupedAnnotations.foreshadow.length,
plotPoints: groupedAnnotations.plot_point.length,
characterEvents: groupedAnnotations.character_event.length,
};
}, [annotations, groupedAnnotations]);
// 渲染单个记忆卡片
const renderMemoryCard = (annotation: MemoryAnnotation) => {
const config = TYPE_CONFIG[annotation.type];
const isActive = activeAnnotationId === annotation.id;
return (
<div
key={annotation.id}
ref={(el) => {
cardRefs.current[annotation.id] = el;
}}
>
<Card
size="small"
hoverable
onClick={() => onAnnotationClick?.(annotation)}
style={{
marginBottom: 12,
borderLeft: `4px solid ${config.color}`,
backgroundColor: isActive ? `${config.color}11` : 'transparent',
cursor: 'pointer',
transition: 'all 0.2s',
}}
bodyStyle={{ padding: 12 }}
>
<div style={{ marginBottom: 8 }}>
<Badge
count={`${(annotation.importance * 10).toFixed(1)}`}
style={{
backgroundColor: config.color,
float: 'right',
}}
/>
<div style={{ fontWeight: 600, fontSize: 14, paddingRight: 50 }}>
{config.icon} {annotation.title}
</div>
</div>
<div
style={{
fontSize: 13,
color: '#666',
lineHeight: 1.6,
marginBottom: 8,
}}
>
{annotation.content.length > 100
? `${annotation.content.slice(0, 100)}...`
: annotation.content}
</div>
{annotation.tags && annotation.tags.length > 0 && (
<div>
{annotation.tags.map((tag, index) => (
<Tag key={index} style={{ fontSize: 11, margin: '2px 4px 2px 0' }}>
{tag}
</Tag>
))}
</div>
)}
{/* 特殊元数据 */}
{annotation.metadata.strength && (
<div style={{ marginTop: 4, fontSize: 11, color: '#999' }}>
: {annotation.metadata.strength}/10
</div>
)}
{annotation.metadata.foreshadowType && (
<Tag
color={annotation.metadata.foreshadowType === 'planted' ? 'blue' : 'green'}
style={{ marginTop: 4 }}
>
{annotation.metadata.foreshadowType === 'planted' ? '已埋下' : '已回收'}
</Tag>
)}
</Card>
</div>
);
};
if (annotations.length === 0) {
return (
<div style={{ padding: 24 }}>
<Empty description="暂无分析数据" />
</div>
);
}
return (
<div style={{ height: '100%', overflowY: 'auto', padding: '16px' }}>
{/* 统计概览 */}
<Card size="small" style={{ marginBottom: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 12 }}>📊 </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<div style={{ fontSize: 12, color: '#999' }}></div>
<div style={{ fontSize: 20, fontWeight: 600, color: TYPE_CONFIG.hook.color }}>
{stats.hooks}
</div>
</div>
<div>
<div style={{ fontSize: 12, color: '#999' }}></div>
<div style={{ fontSize: 20, fontWeight: 600, color: TYPE_CONFIG.foreshadow.color }}>
{stats.foreshadows}
</div>
</div>
<div>
<div style={{ fontSize: 12, color: '#999' }}></div>
<div style={{ fontSize: 20, fontWeight: 600, color: TYPE_CONFIG.plot_point.color }}>
{stats.plotPoints}
</div>
</div>
<div>
<div style={{ fontSize: 12, color: '#999' }}></div>
<div
style={{ fontSize: 20, fontWeight: 600, color: TYPE_CONFIG.character_event.color }}
>
{stats.characterEvents}
</div>
</div>
</div>
</Card>
<Divider style={{ margin: '16px 0' }} />
{/* 分类展示 */}
<Collapse defaultActiveKey={['hook', 'foreshadow', 'plot_point']} ghost>
{Object.entries(groupedAnnotations).map(([type, items]) => {
if (items.length === 0) return null;
const config = TYPE_CONFIG[type as keyof typeof TYPE_CONFIG];
return (
<Panel
key={type}
header={
<span style={{ fontWeight: 600 }}>
{config.icon} {config.label} ({items.length})
</span>
}
>
{items.map((annotation) => renderMemoryCard(annotation))}
</Panel>
);
})}
</Collapse>
</div>
);
};
export default MemorySidebar;