update:1.更新导入导出功能 2.实现RAG记忆功能,引入剧情分析功能
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user