Merge pull request #2 from xiamuceer-j/dev

Dev
This commit is contained in:
夏目侧耳
2025-11-05 00:29:41 +08:00
committed by GitHub
48 changed files with 7177 additions and 112 deletions
+4
View File
@@ -40,6 +40,10 @@ Thumbs.db
data/*.db
backend/data/*.db
# ChromaDB数据库(不包含在镜像中,会在运行时生成)
backend/data/chroma_db/
# 日志文件
logs/
*.log
+1
View File
@@ -0,0 +1 @@
*.safetensors filter=lfs diff=lfs merge=lfs -text
+15 -2
View File
@@ -40,7 +40,10 @@ RUN apt-get update && apt-get install -y \
# 复制后端依赖文件
COPY backend/requirements.txt ./
# 安装Python依赖
# 先从PyTorch官方源安装CPU版本的torch(避免GPU依赖
RUN pip install --no-cache-dir torch==2.7.0 --index-url https://download.pytorch.org/whl/cpu
# 再安装其他Python依赖(使用阿里云镜像加速)
RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/
# 复制后端代码
@@ -50,7 +53,11 @@ COPY backend/ ./
COPY --from=frontend-builder /frontend/dist ./static
# 创建必要的目录
RUN mkdir -p /app/data /app/logs
RUN mkdir -p /app/data /app/logs /app/embedding
# 复制预下载的Embedding模型到独立目录(避免被docker-compose的data挂载覆盖)
# 这样可以避免首次运行时联网下载约420MB的模型文件
COPY backend/embedding /app/embedding
# 复制环境变量示例文件
COPY backend/.env.example ./.env.example
@@ -63,6 +70,12 @@ ENV PYTHONUNBUFFERED=1
ENV APP_HOST=0.0.0.0
ENV APP_PORT=8000
# 设置Transformers和Sentence-Transformers离线模式
ENV TRANSFORMERS_OFFLINE=1
ENV HF_DATASETS_OFFLINE=1
ENV HF_HUB_OFFLINE=1
ENV SENTENCE_TRANSFORMERS_HOME=/app/embedding
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
+734 -6
View File
@@ -1,11 +1,13 @@
"""章节管理API"""
from fastapi import APIRouter, Depends, HTTPException, Request, Query
from fastapi import APIRouter, Depends, HTTPException, Request, Query, BackgroundTasks
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
import json
import asyncio
from typing import Optional
from datetime import datetime
from asyncio import Queue, Lock
from app.database import get_db
from app.models.chapter import Chapter
@@ -14,6 +16,8 @@ from app.models.outline import Outline
from app.models.character import Character
from app.models.generation_history import GenerationHistory
from app.models.writing_style import WritingStyle
from app.models.analysis_task import AnalysisTask
from app.models.memory import PlotAnalysis, StoryMemory
from app.schemas.chapter import (
ChapterCreate,
ChapterUpdate,
@@ -23,12 +27,25 @@ from app.schemas.chapter import (
)
from app.services.ai_service import AIService
from app.services.prompt_service import prompt_service
from app.services.plot_analyzer import PlotAnalyzer
from app.services.memory_service import memory_service
from app.logger import get_logger
from app.api.settings import get_user_ai_service
router = APIRouter(prefix="/chapters", tags=["章节管理"])
logger = get_logger(__name__)
# 全局数据库写入锁(每个用户一个锁,用于保护SQLite写入操作)
db_write_locks: dict[str, Lock] = {}
async def get_db_write_lock(user_id: str) -> Lock:
"""获取或创建用户的数据库写入锁"""
if user_id not in db_write_locks:
db_write_locks[user_id] = Lock()
logger.debug(f"🔒 为用户 {user_id} 创建数据库写入锁")
return db_write_locks[user_id]
@router.post("", response_model=ChapterResponse, summary="创建章节")
async def create_chapter(
@@ -101,6 +118,63 @@ async def get_chapter(
return chapter
@router.get("/{chapter_id}/navigation", summary="获取章节导航信息")
async def get_chapter_navigation(
chapter_id: str,
db: AsyncSession = Depends(get_db)
):
"""
获取章节的导航信息(上一章/下一章)
用于章节阅读器的翻页功能
"""
# 获取当前章节
result = await db.execute(
select(Chapter).where(Chapter.id == chapter_id)
)
current_chapter = result.scalar_one_or_none()
if not current_chapter:
raise HTTPException(status_code=404, detail="章节不存在")
# 获取上一章
prev_result = await db.execute(
select(Chapter)
.where(Chapter.project_id == current_chapter.project_id)
.where(Chapter.chapter_number < current_chapter.chapter_number)
.order_by(Chapter.chapter_number.desc())
.limit(1)
)
prev_chapter = prev_result.scalar_one_or_none()
# 获取下一章
next_result = await db.execute(
select(Chapter)
.where(Chapter.project_id == current_chapter.project_id)
.where(Chapter.chapter_number > current_chapter.chapter_number)
.order_by(Chapter.chapter_number.asc())
.limit(1)
)
next_chapter = next_result.scalar_one_or_none()
return {
"current": {
"id": current_chapter.id,
"chapter_number": current_chapter.chapter_number,
"title": current_chapter.title
},
"previous": {
"id": prev_chapter.id,
"chapter_number": prev_chapter.chapter_number,
"title": prev_chapter.title
} if prev_chapter else None,
"next": {
"id": next_chapter.id,
"chapter_number": next_chapter.chapter_number,
"title": next_chapter.title
} if next_chapter else None
}
@router.put("/{chapter_id}", response_model=ChapterResponse, summary="更新章节")
async def update_chapter(
chapter_id: str,
@@ -248,10 +322,277 @@ async def check_can_generate(
}
async def analyze_chapter_background(
chapter_id: str,
user_id: str,
project_id: str,
task_id: str,
ai_service: AIService
):
"""
后台异步分析章节(支持并发,使用锁保护数据库写入)
Args:
chapter_id: 章节ID
user_id: 用户ID
project_id: 项目ID
task_id: 任务ID
ai_service: AI服务实例
"""
db_session = None
write_lock = await get_db_write_lock(user_id)
try:
logger.info(f"🔍 开始分析章节: {chapter_id}, 任务ID: {task_id}")
# 创建独立数据库会话
from app.database import get_engine
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
engine = await get_engine(user_id)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
db_session = AsyncSessionLocal()
# 1. 获取任务(读操作)
task_result = await db_session.execute(
select(AnalysisTask).where(AnalysisTask.id == task_id)
)
task = task_result.scalar_one_or_none()
if not task:
logger.error(f"❌ 任务不存在: {task_id}")
return
# 更新任务状态(写操作,需要锁)
async with write_lock:
task.status = 'running'
task.started_at = datetime.now()
task.progress = 10
await db_session.commit()
# 2. 获取章节信息(读操作)
chapter_result = await db_session.execute(
select(Chapter).where(Chapter.id == chapter_id)
)
chapter = chapter_result.scalar_one_or_none()
if not chapter or not chapter.content:
async with write_lock:
task.status = 'failed'
task.error_message = '章节不存在或内容为空'
task.completed_at = datetime.now()
await db_session.commit()
logger.error(f"❌ 章节不存在或内容为空: {chapter_id}")
return
async with write_lock:
task.progress = 20
await db_session.commit()
# 3. 使用PlotAnalyzer分析章节
analyzer = PlotAnalyzer(ai_service)
analysis_result = await analyzer.analyze_chapter(
chapter_number=chapter.chapter_number,
title=chapter.title,
content=chapter.content,
word_count=chapter.word_count or len(chapter.content)
)
if not analysis_result:
async with write_lock:
task.status = 'failed'
task.error_message = 'AI分析失败,请检查日志'
task.completed_at = datetime.now()
await db_session.commit()
logger.error(f"❌ AI分析失败: {chapter_id}")
return
async with write_lock:
task.progress = 60
await db_session.commit()
# 4. 保存分析结果到数据库(写操作,需要锁)
async with write_lock:
existing_analysis_result = await db_session.execute(
select(PlotAnalysis).where(PlotAnalysis.chapter_id == chapter_id)
)
existing_analysis = existing_analysis_result.scalar_one_or_none()
if existing_analysis:
# 更新现有记录
logger.info(f" 更新现有分析记录: {existing_analysis.id}")
existing_analysis.plot_stage = analysis_result.get('plot_stage', '发展')
existing_analysis.conflict_level = analysis_result.get('conflict', {}).get('level', 0)
existing_analysis.conflict_types = analysis_result.get('conflict', {}).get('types', [])
existing_analysis.emotional_tone = analysis_result.get('emotional_arc', {}).get('primary_emotion', '')
existing_analysis.emotional_intensity = analysis_result.get('emotional_arc', {}).get('intensity', 0) / 10.0
existing_analysis.hooks = analysis_result.get('hooks', [])
existing_analysis.hooks_count = len(analysis_result.get('hooks', []))
existing_analysis.foreshadows = analysis_result.get('foreshadows', [])
existing_analysis.foreshadows_planted = sum(1 for f in analysis_result.get('foreshadows', []) if f.get('type') == 'planted')
existing_analysis.foreshadows_resolved = sum(1 for f in analysis_result.get('foreshadows', []) if f.get('type') == 'resolved')
existing_analysis.plot_points = analysis_result.get('plot_points', [])
existing_analysis.plot_points_count = len(analysis_result.get('plot_points', []))
existing_analysis.character_states = analysis_result.get('character_states', [])
existing_analysis.scenes = analysis_result.get('scenes', [])
existing_analysis.pacing = analysis_result.get('pacing', 'moderate')
existing_analysis.overall_quality_score = analysis_result.get('scores', {}).get('overall', 0)
existing_analysis.pacing_score = analysis_result.get('scores', {}).get('pacing', 0)
existing_analysis.engagement_score = analysis_result.get('scores', {}).get('engagement', 0)
existing_analysis.coherence_score = analysis_result.get('scores', {}).get('coherence', 0)
existing_analysis.analysis_report = analyzer.generate_analysis_summary(analysis_result)
existing_analysis.suggestions = analysis_result.get('suggestions', [])
existing_analysis.dialogue_ratio = analysis_result.get('dialogue_ratio', 0)
existing_analysis.description_ratio = analysis_result.get('description_ratio', 0)
else:
# 创建新记录
logger.info(f" 创建新的分析记录")
plot_analysis = PlotAnalysis(
chapter_id=chapter_id,
project_id=project_id,
plot_stage=analysis_result.get('plot_stage', '发展'),
conflict_level=analysis_result.get('conflict', {}).get('level', 0),
conflict_types=analysis_result.get('conflict', {}).get('types', []),
emotional_tone=analysis_result.get('emotional_arc', {}).get('primary_emotion', ''),
emotional_intensity=analysis_result.get('emotional_arc', {}).get('intensity', 0) / 10.0,
hooks=analysis_result.get('hooks', []),
hooks_count=len(analysis_result.get('hooks', [])),
foreshadows=analysis_result.get('foreshadows', []),
foreshadows_planted=sum(1 for f in analysis_result.get('foreshadows', []) if f.get('type') == 'planted'),
foreshadows_resolved=sum(1 for f in analysis_result.get('foreshadows', []) if f.get('type') == 'resolved'),
plot_points=analysis_result.get('plot_points', []),
plot_points_count=len(analysis_result.get('plot_points', [])),
character_states=analysis_result.get('character_states', []),
scenes=analysis_result.get('scenes', []),
pacing=analysis_result.get('pacing', 'moderate'),
overall_quality_score=analysis_result.get('scores', {}).get('overall', 0),
pacing_score=analysis_result.get('scores', {}).get('pacing', 0),
engagement_score=analysis_result.get('scores', {}).get('engagement', 0),
coherence_score=analysis_result.get('scores', {}).get('coherence', 0),
analysis_report=analyzer.generate_analysis_summary(analysis_result),
suggestions=analysis_result.get('suggestions', []),
dialogue_ratio=analysis_result.get('dialogue_ratio', 0),
description_ratio=analysis_result.get('description_ratio', 0)
)
db_session.add(plot_analysis)
await db_session.commit()
task.progress = 80
await db_session.commit()
# 5. 提取记忆并保存到向量数据库(传入章节内容用于计算位置)
memories = analyzer.extract_memories_from_analysis(
analysis=analysis_result,
chapter_id=chapter_id,
chapter_number=chapter.chapter_number,
chapter_content=chapter.content or ""
)
# 先删除该章节的旧记忆(写操作,需要锁)
async with write_lock:
old_memories_result = await db_session.execute(
select(StoryMemory).where(StoryMemory.chapter_id == chapter_id)
)
old_memories = old_memories_result.scalars().all()
for old_mem in old_memories:
await db_session.delete(old_mem)
await db_session.commit()
logger.info(f" 删除旧记忆: {len(old_memories)}")
# 准备批量添加的记忆数据(不需要锁)
memory_records = []
for mem in memories:
memory_id = f"{chapter_id}_{mem['type']}_{len(memory_records)}"
memory_records.append({
'id': memory_id,
'content': mem['content'],
'type': mem['type'],
'metadata': mem['metadata']
})
# 保存到关系数据库(写操作,需要锁)
async with write_lock:
for mem in memories:
memory_id = memory_records[memories.index(mem)]['id']
text_position = mem['metadata'].get('text_position', -1)
text_length = mem['metadata'].get('text_length', 0)
story_memory = StoryMemory(
id=memory_id,
project_id=project_id,
chapter_id=chapter_id,
memory_type=mem['type'],
content=mem['content'],
title=mem['title'],
importance_score=mem['metadata'].get('importance_score', 0.5),
tags=mem['metadata'].get('tags', []),
is_foreshadow=mem['metadata'].get('is_foreshadow', 0),
story_timeline=chapter.chapter_number,
chapter_position=text_position,
text_length=text_length,
related_characters=mem['metadata'].get('related_characters', []),
related_locations=mem['metadata'].get('related_locations', [])
)
db_session.add(story_memory)
if text_position >= 0:
logger.debug(f" 保存记忆 {memory_id}: position={text_position}, length={text_length}")
await db_session.commit()
# 批量添加到向量数据库
if memory_records:
added_count = await memory_service.batch_add_memories(
user_id=user_id,
project_id=project_id,
memories=memory_records
)
logger.info(f"✅ 添加{added_count}条记忆到向量库")
# 最终更新任务状态(写操作,需要锁)
async with write_lock:
task.progress = 100
task.status = 'completed'
task.completed_at = datetime.now()
await db_session.commit()
logger.info(f"✅ 章节分析完成: {chapter_id}, 提取{len(memories)}条记忆")
except Exception as e:
logger.error(f"❌ 后台分析异常: {str(e)}", exc_info=True)
# 确保任务状态被更新为failed(写操作,需要锁)
if db_session:
try:
async with write_lock:
task_result = await db_session.execute(
select(AnalysisTask).where(AnalysisTask.id == task_id)
)
task = task_result.scalar_one_or_none()
if task:
task.status = 'failed'
task.error_message = str(e)[:500]
task.completed_at = datetime.now()
task.progress = 0
await db_session.commit()
logger.info(f"✅ 任务状态已更新为failed: {task_id}")
else:
logger.error(f"❌ 无法找到任务进行状态更新: {task_id}")
except Exception as update_error:
logger.error(f"❌ 更新任务状态失败: {str(update_error)}")
finally:
if db_session:
await db_session.close()
@router.post("/{chapter_id}/generate-stream", summary="AI创作章节内容(流式)")
async def generate_chapter_content_stream(
chapter_id: str,
request: Request,
background_tasks: BackgroundTasks,
generate_request: ChapterGenerateRequest = ChapterGenerateRequest(),
user_ai_service: AIService = Depends(get_user_ai_service)
):
@@ -301,6 +642,9 @@ async def generate_chapter_content_stream(
# 在生成器内部创建独立的数据库会话
db_session = None
db_committed = False
# 获取当前用户ID(在生成器外部就需要)
current_user_id = getattr(request.state, "user_id", "system")
try:
# 创建新的数据库会话
async for db_session in get_db(request):
@@ -396,11 +740,42 @@ async def generate_chapter_content_stream(
previous_content += recent_content
logger.info(f"构建前置上下文:{len(early_chapters)}章摘要 + {len(recent_chapters)}章完整内容")
# 🧠 构建记忆增强上下文
logger.info(f"🧠 开始构建记忆增强上下文...")
memory_context = await memory_service.build_context_for_generation(
user_id=current_user_id,
project_id=project.id,
current_chapter=current_chapter.chapter_number,
chapter_outline=outline.content if outline else current_chapter.summary or "",
character_names=[c.name for c in characters] if characters else None
)
# 计算各部分的字符长度
context_lengths = {
'recent_context': len(memory_context.get('recent_context', '')),
'relevant_memories': len(memory_context.get('relevant_memories', '')),
'foreshadows': len(memory_context.get('foreshadows', '')),
'character_states': len(memory_context.get('character_states', '')),
'plot_points': len(memory_context.get('plot_points', ''))
}
total_memory_length = sum(context_lengths.values())
logger.info(f"✅ 记忆上下文构建完成: {memory_context['stats']}")
logger.info(f"📏 记忆上下文长度统计:")
logger.info(f" - 最近章节记忆: {context_lengths['recent_context']} 字符")
logger.info(f" - 语义相关记忆: {context_lengths['relevant_memories']} 字符")
logger.info(f" - 未完结伏笔: {context_lengths['foreshadows']} 字符")
logger.info(f" - 角色状态记忆: {context_lengths['character_states']} 字符")
logger.info(f" - 重要情节点: {context_lengths['plot_points']} 字符")
logger.info(f" - 记忆总长度: {total_memory_length} 字符")
logger.info(f" - 前置章节上下文长度: {len(previous_content)} 字符")
logger.info(f" - 总上下文长度(估算): {total_memory_length + len(previous_content) + 2000} 字符")
# 发送开始事件
yield f"data: {json.dumps({'type': 'start', 'message': '开始AI创作...'}, ensure_ascii=False)}\n\n"
# 根据是否有前置内容选择不同的提示词,并应用写作风格
# 根据是否有前置内容选择不同的提示词,并应用写作风格和记忆增强
if previous_content:
prompt = prompt_service.get_chapter_generation_with_context_prompt(
title=project.title,
@@ -418,7 +793,8 @@ async def generate_chapter_content_stream(
chapter_title=current_chapter.title,
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲',
style_content=style_content,
target_word_count=target_word_count
target_word_count=target_word_count,
memory_context=memory_context
)
else:
prompt = prompt_service.get_chapter_generation_prompt(
@@ -436,7 +812,8 @@ async def generate_chapter_content_stream(
chapter_title=current_chapter.title,
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲',
style_content=style_content,
target_word_count=target_word_count
target_word_count=target_word_count,
memory_context=memory_context
)
logger.info(f"开始AI流式创作章节 {chapter_id}")
@@ -474,8 +851,50 @@ async def generate_chapter_content_stream(
logger.info(f"成功创作章节 {chapter_id},共 {new_word_count}")
# 发送完成事件
yield f"data: {json.dumps({'type': 'done', 'message': '创作完成', 'word_count': new_word_count}, ensure_ascii=False)}\n\n"
# 创建分析任务
analysis_task = AnalysisTask(
chapter_id=chapter_id,
user_id=current_user_id,
project_id=project.id,
status='pending',
progress=0
)
db_session.add(analysis_task)
await db_session.commit()
await db_session.refresh(analysis_task)
task_id = analysis_task.id
logger.info(f"📋 已创建分析任务: {task_id}")
# 短暂延迟确保SQLite WAL完成写入
await asyncio.sleep(0.05)
# 直接启动后台分析(并发执行)
background_tasks.add_task(
analyze_chapter_background,
chapter_id=chapter_id,
user_id=current_user_id,
project_id=project.id,
task_id=task_id,
ai_service=user_ai_service
)
# 发送完成事件(包含分析任务ID
completion_data = {
'type': 'done',
'message': '创作完成',
'word_count': new_word_count,
'analysis_task_id': task_id
}
yield f"data: {json.dumps(completion_data, ensure_ascii=False)}\n\n"
# 发送分析开始事件
analysis_started_data = {
'type': 'analysis_started',
'task_id': task_id,
'message': '章节分析已开始'
}
yield f"data: {json.dumps(analysis_started_data, ensure_ascii=False)}\n\n"
break # 退出async for db_session循环
@@ -527,3 +946,312 @@ async def generate_chapter_content_stream(
"X-Accel-Buffering": "no"
}
)
@router.get("/{chapter_id}/analysis/status", summary="查询章节分析任务状态")
async def get_analysis_task_status(
chapter_id: str,
db: AsyncSession = Depends(get_db)
):
"""
查询指定章节的最新分析任务状态
返回:
- task_id: 任务ID
- status: pending/running/completed/failed
- progress: 0-100
- error_message: 错误信息(如果失败)
- created_at: 创建时间
- completed_at: 完成时间
"""
# 获取该章节最新的分析任务
result = await db.execute(
select(AnalysisTask)
.where(AnalysisTask.chapter_id == chapter_id)
.order_by(AnalysisTask.created_at.desc())
.limit(1)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="未找到分析任务")
return {
"task_id": task.id,
"chapter_id": task.chapter_id,
"status": task.status,
"progress": task.progress,
"error_message": task.error_message,
"created_at": task.created_at.isoformat() if task.created_at else None,
"started_at": task.started_at.isoformat() if task.started_at else None,
"completed_at": task.completed_at.isoformat() if task.completed_at else None
}
@router.get("/{chapter_id}/analysis", summary="获取章节分析结果")
async def get_chapter_analysis(
chapter_id: str,
db: AsyncSession = Depends(get_db)
):
"""
获取章节的完整分析结果
返回:
- analysis_data: 完整的分析数据(JSON)
- summary: 分析摘要文本
- memories: 提取的记忆列表
- created_at: 分析时间
"""
# 获取分析结果
analysis_result = await db.execute(
select(PlotAnalysis)
.where(PlotAnalysis.chapter_id == chapter_id)
.order_by(PlotAnalysis.created_at.desc())
.limit(1)
)
analysis = analysis_result.scalar_one_or_none()
if not analysis:
raise HTTPException(status_code=404, detail="该章节暂无分析结果")
# 获取相关记忆
memories_result = await db.execute(
select(StoryMemory)
.where(StoryMemory.chapter_id == chapter_id)
.order_by(StoryMemory.importance_score.desc())
)
memories = memories_result.scalars().all()
return {
"chapter_id": chapter_id,
"analysis": analysis.to_dict(), # 使用to_dict()方法
"memories": [
{
"id": mem.id,
"type": mem.memory_type,
"title": mem.title,
"content": mem.content,
"importance": mem.importance_score,
"tags": mem.tags,
"is_foreshadow": mem.is_foreshadow,
"position": mem.chapter_position,
"related_characters": mem.related_characters
}
for mem in memories
],
"created_at": analysis.created_at.isoformat() if analysis.created_at else None
}
@router.get("/{chapter_id}/annotations", summary="获取章节标注数据")
async def get_chapter_annotations(
chapter_id: str,
db: AsyncSession = Depends(get_db)
):
"""
获取章节的标注数据(用于前端展示标注)
返回格式化的标注列表,包含精确位置信息
适用于章节内容的可视化标注展示
"""
# 获取章节
chapter_result = await db.execute(
select(Chapter).where(Chapter.id == chapter_id)
)
chapter = chapter_result.scalar_one_or_none()
if not chapter:
raise HTTPException(status_code=404, detail="章节不存在")
# 获取分析结果
analysis_result = await db.execute(
select(PlotAnalysis)
.where(PlotAnalysis.chapter_id == chapter_id)
.order_by(PlotAnalysis.created_at.desc())
.limit(1)
)
analysis = analysis_result.scalar_one_or_none()
# 获取记忆
memories_result = await db.execute(
select(StoryMemory)
.where(StoryMemory.chapter_id == chapter_id)
.order_by(StoryMemory.importance_score.desc())
)
memories = memories_result.scalars().all()
# 构建标注数据
annotations = []
for mem in memories:
# 优先从数据库读取位置信息
position = mem.chapter_position if mem.chapter_position is not None else -1
length = mem.text_length if hasattr(mem, 'text_length') and mem.text_length is not None else 0
metadata_extra = {}
# 如果数据库中没有位置信息,尝试从分析数据中重新计算
if position == -1 and analysis and chapter.content:
# 根据记忆类型从分析数据中查找对应项
if mem.memory_type == 'hook' and analysis.hooks:
for hook in analysis.hooks:
# 通过标题或内容匹配
if mem.title and hook.get('type') in mem.title:
keyword = hook.get('keyword', '')
if keyword:
pos = chapter.content.find(keyword)
if pos != -1:
position = pos
length = len(keyword)
metadata_extra["strength"] = hook.get('strength', 5)
metadata_extra["position_desc"] = hook.get('position', '')
break
elif mem.memory_type == 'foreshadow' and analysis.foreshadows:
for foreshadow in analysis.foreshadows:
if foreshadow.get('content') in mem.content:
keyword = foreshadow.get('keyword', '')
if keyword:
pos = chapter.content.find(keyword)
if pos != -1:
position = pos
length = len(keyword)
metadata_extra["foreshadow_type"] = foreshadow.get('type', 'planted')
metadata_extra["strength"] = foreshadow.get('strength', 5)
break
elif mem.memory_type == 'plot_point' and analysis.plot_points:
for plot_point in analysis.plot_points:
if plot_point.get('content') in mem.content:
keyword = plot_point.get('keyword', '')
if keyword:
pos = chapter.content.find(keyword)
if pos != -1:
position = pos
length = len(keyword)
break
else:
# 如果数据库有位置,也从分析数据中提取额外的元数据
if analysis:
if mem.memory_type == 'hook' and analysis.hooks:
for hook in analysis.hooks:
if mem.title and hook.get('type') in mem.title:
metadata_extra["strength"] = hook.get('strength', 5)
metadata_extra["position_desc"] = hook.get('position', '')
break
elif mem.memory_type == 'foreshadow' and analysis.foreshadows:
for foreshadow in analysis.foreshadows:
if foreshadow.get('content') in mem.content:
metadata_extra["foreshadow_type"] = foreshadow.get('type', 'planted')
metadata_extra["strength"] = foreshadow.get('strength', 5)
break
annotation = {
"id": mem.id,
"type": mem.memory_type,
"title": mem.title,
"content": mem.content,
"importance": mem.importance_score or 0.5,
"position": position,
"length": length,
"tags": mem.tags or [],
"metadata": {
"is_foreshadow": mem.is_foreshadow,
"related_characters": mem.related_characters or [],
"related_locations": mem.related_locations or [],
**metadata_extra
}
}
annotations.append(annotation)
return {
"chapter_id": chapter_id,
"chapter_number": chapter.chapter_number,
"title": chapter.title,
"word_count": chapter.word_count or 0,
"annotations": annotations,
"has_analysis": analysis is not None,
"summary": {
"total_annotations": len(annotations),
"hooks": len([a for a in annotations if a["type"] == "hook"]),
"foreshadows": len([a for a in annotations if a["type"] == "foreshadow"]),
"plot_points": len([a for a in annotations if a["type"] == "plot_point"]),
"character_events": len([a for a in annotations if a["type"] == "character_event"])
}
}
@router.post("/{chapter_id}/analyze", summary="手动触发章节分析")
async def trigger_chapter_analysis(
chapter_id: str,
request: Request,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
):
"""
手动触发章节分析(用于重新分析或分析旧章节)
"""
# 从请求中获取用户ID
user_id = getattr(request.state, "user_id", None)
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
# 验证章节存在
chapter_result = await db.execute(
select(Chapter).where(Chapter.id == chapter_id)
)
chapter = chapter_result.scalar_one_or_none()
if not chapter:
raise HTTPException(status_code=404, detail="章节不存在")
if not chapter.content or chapter.content.strip() == "":
raise HTTPException(status_code=400, detail="章节内容为空,无法分析")
# 获取项目信息
project_result = await db.execute(
select(Project).where(Project.id == chapter.project_id)
)
project = project_result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 创建分析任务
analysis_task = AnalysisTask(
chapter_id=chapter_id,
user_id=user_id,
project_id=project.id,
status='pending',
progress=0
)
db.add(analysis_task)
await db.commit()
task_id = analysis_task.id
logger.info(f"📋 创建分析任务: {task_id}, 章节: {chapter_id}")
# 刷新数据库会话,确保其他会话可以看到新任务
await db.refresh(analysis_task)
# 短暂延迟确保SQLite WAL完成写入(让其他会话可见)
await asyncio.sleep(3)
# 直接启动后台分析(并发执行)
background_tasks.add_task(
analyze_chapter_background,
chapter_id=chapter_id,
user_id=user_id,
project_id=project.id,
task_id=task_id,
ai_service=user_ai_service
)
return {
"task_id": task_id,
"chapter_id": chapter_id,
"status": "pending",
"message": "分析任务已创建并开始执行"
}
+383
View File
@@ -0,0 +1,383 @@
"""记忆管理API - 提供记忆的查询、分析等接口"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, desc
from typing import List, Optional
from app.database import get_db
from app.models.memory import StoryMemory, PlotAnalysis
from app.models.chapter import Chapter
from app.services.memory_service import memory_service
from app.services.plot_analyzer import get_plot_analyzer
from app.services.ai_service import create_user_ai_service
from app.models.settings import Settings
from app.logger import get_logger
import uuid
logger = get_logger(__name__)
router = APIRouter(prefix="/api/memories", tags=["memories"])
@router.post("/projects/{project_id}/analyze-chapter/{chapter_id}")
async def analyze_chapter(
project_id: str,
chapter_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
分析章节并生成记忆
对指定章节进行剧情分析,提取钩子、伏笔、情节点等,并存入记忆系统
"""
try:
user_id = request.state.user_id
# 获取章节内容
result = await db.execute(
select(Chapter).where(
and_(
Chapter.id == chapter_id,
Chapter.project_id == project_id
)
)
)
chapter = result.scalar_one_or_none()
if not chapter:
raise HTTPException(status_code=404, detail="章节不存在")
if not chapter.content:
raise HTTPException(status_code=400, detail="章节内容为空,无法分析")
# 获取用户AI设置
settings_result = await db.execute(select(Settings))
settings = settings_result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=400, detail="请先配置AI设置")
# 创建AI服务
ai_service = create_user_ai_service(
api_provider=settings.api_provider,
api_key=settings.api_key,
api_base_url=settings.api_base_url,
model_name=settings.llm_model,
temperature=settings.temperature,
max_tokens=settings.max_tokens
)
# 执行剧情分析
analyzer = get_plot_analyzer(ai_service)
analysis_result = await analyzer.analyze_chapter(
chapter_number=chapter.chapter_number,
title=chapter.title,
content=chapter.content,
word_count=chapter.word_count or len(chapter.content)
)
if not analysis_result:
raise HTTPException(status_code=500, detail="剧情分析失败")
# 保存分析结果到数据库
plot_analysis = PlotAnalysis(
id=str(uuid.uuid4()),
project_id=project_id,
chapter_id=chapter_id,
plot_stage=analysis_result.get('plot_stage'),
conflict_level=analysis_result.get('conflict', {}).get('level'),
conflict_types=analysis_result.get('conflict', {}).get('types'),
emotional_tone=analysis_result.get('emotional_arc', {}).get('primary_emotion'),
emotional_intensity=analysis_result.get('emotional_arc', {}).get('intensity', 0) / 10,
emotional_curve=analysis_result.get('emotional_arc'),
hooks=analysis_result.get('hooks'),
hooks_count=len(analysis_result.get('hooks', [])),
hooks_avg_strength=sum(h.get('strength', 0) for h in analysis_result.get('hooks', [])) / max(len(analysis_result.get('hooks', [])), 1),
foreshadows=analysis_result.get('foreshadows'),
foreshadows_planted=sum(1 for f in analysis_result.get('foreshadows', []) if f.get('type') == 'planted'),
foreshadows_resolved=sum(1 for f in analysis_result.get('foreshadows', []) if f.get('type') == 'resolved'),
plot_points=analysis_result.get('plot_points'),
plot_points_count=len(analysis_result.get('plot_points', [])),
character_states=analysis_result.get('character_states'),
scenes=analysis_result.get('scenes'),
pacing=analysis_result.get('pacing'),
dialogue_ratio=analysis_result.get('dialogue_ratio'),
description_ratio=analysis_result.get('description_ratio'),
overall_quality_score=analysis_result.get('scores', {}).get('overall'),
pacing_score=analysis_result.get('scores', {}).get('pacing'),
engagement_score=analysis_result.get('scores', {}).get('engagement'),
coherence_score=analysis_result.get('scores', {}).get('coherence'),
analysis_report=analyzer.generate_analysis_summary(analysis_result),
suggestions=analysis_result.get('suggestions'),
word_count=chapter.word_count
)
# 检查是否已存在分析记录
existing = await db.execute(
select(PlotAnalysis).where(PlotAnalysis.chapter_id == chapter_id)
)
if existing.scalar_one_or_none():
# 删除旧记录
await db.execute(
select(PlotAnalysis).where(PlotAnalysis.chapter_id == chapter_id)
)
await db.delete(existing.scalar_one())
db.add(plot_analysis)
await db.commit()
# 从分析结果中提取记忆片段
memories_data = analyzer.extract_memories_from_analysis(
analysis_result,
chapter_id,
chapter.chapter_number
)
# 保存记忆到数据库和向量库
saved_count = 0
for mem_data in memories_data:
memory_id = str(uuid.uuid4())
# 保存到关系数据库
memory = StoryMemory(
id=memory_id,
project_id=project_id,
chapter_id=chapter_id,
memory_type=mem_data['type'],
title=mem_data.get('title', ''),
content=mem_data['content'],
story_timeline=chapter.chapter_number,
vector_id=memory_id,
**mem_data['metadata']
)
db.add(memory)
# 保存到向量库
await memory_service.add_memory(
user_id=user_id,
project_id=project_id,
memory_id=memory_id,
content=mem_data['content'],
memory_type=mem_data['type'],
metadata=mem_data['metadata']
)
saved_count += 1
await db.commit()
logger.info(f"✅ 章节分析完成: 保存{saved_count}条记忆")
return {
"success": True,
"message": f"分析完成,提取了{saved_count}条记忆",
"analysis": plot_analysis.to_dict(),
"memories_count": saved_count
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ 章节分析失败: {str(e)}")
await db.rollback()
raise HTTPException(status_code=500, detail=f"分析失败: {str(e)}")
@router.get("/projects/{project_id}/memories")
async def get_project_memories(
project_id: str,
request: Request,
memory_type: Optional[str] = None,
chapter_id: Optional[str] = None,
limit: int = 50,
db: AsyncSession = Depends(get_db)
):
"""获取项目的记忆列表"""
try:
user_id = request.state.user_id
# 构建查询
query = select(StoryMemory).where(StoryMemory.project_id == project_id)
if memory_type:
query = query.where(StoryMemory.memory_type == memory_type)
if chapter_id:
query = query.where(StoryMemory.chapter_id == chapter_id)
query = query.order_by(desc(StoryMemory.importance_score), desc(StoryMemory.created_at)).limit(limit)
result = await db.execute(query)
memories = result.scalars().all()
return {
"success": True,
"memories": [mem.to_dict() for mem in memories],
"total": len(memories)
}
except Exception as e:
logger.error(f"❌ 获取记忆失败: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/projects/{project_id}/analysis/{chapter_id}")
async def get_chapter_analysis(
project_id: str,
chapter_id: str,
db: AsyncSession = Depends(get_db)
):
"""获取章节的剧情分析"""
try:
result = await db.execute(
select(PlotAnalysis).where(
and_(
PlotAnalysis.project_id == project_id,
PlotAnalysis.chapter_id == chapter_id
)
)
)
analysis = result.scalar_one_or_none()
if not analysis:
raise HTTPException(status_code=404, detail="该章节还未进行分析")
return {
"success": True,
"analysis": analysis.to_dict()
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ 获取分析失败: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/projects/{project_id}/search")
async def search_memories(
project_id: str,
request: Request,
query: str,
memory_types: Optional[List[str]] = None,
limit: int = 10,
min_importance: float = 0.0
):
"""语义搜索项目记忆"""
try:
user_id = request.state.user_id
memories = await memory_service.search_memories(
user_id=user_id,
project_id=project_id,
query=query,
memory_types=memory_types,
limit=limit,
min_importance=min_importance
)
return {
"success": True,
"query": query,
"memories": memories,
"total": len(memories)
}
except Exception as e:
logger.error(f"❌ 搜索记忆失败: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/projects/{project_id}/foreshadows")
async def get_unresolved_foreshadows(
project_id: str,
request: Request,
current_chapter: int,
db: AsyncSession = Depends(get_db)
):
"""获取未完结的伏笔"""
try:
user_id = request.state.user_id
# 从向量库搜索
foreshadows = await memory_service.find_unresolved_foreshadows(
user_id=user_id,
project_id=project_id,
current_chapter=current_chapter
)
return {
"success": True,
"foreshadows": foreshadows,
"total": len(foreshadows)
}
except Exception as e:
logger.error(f"❌ 获取伏笔失败: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/projects/{project_id}/stats")
async def get_memory_stats(
project_id: str,
request: Request
):
"""获取记忆统计信息"""
try:
user_id = request.state.user_id
stats = await memory_service.get_memory_stats(
user_id=user_id,
project_id=project_id
)
return {
"success": True,
"stats": stats
}
except Exception as e:
logger.error(f"❌ 获取统计失败: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/projects/{project_id}/chapters/{chapter_id}/memories")
async def delete_chapter_memories(
project_id: str,
chapter_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""删除章节的所有记忆"""
try:
user_id = request.state.user_id
# 从数据库删除
result = await db.execute(
select(StoryMemory).where(
and_(
StoryMemory.project_id == project_id,
StoryMemory.chapter_id == chapter_id
)
)
)
memories = result.scalars().all()
for memory in memories:
await db.delete(memory)
# 从向量库删除
await memory_service.delete_chapter_memories(
user_id=user_id,
project_id=project_id,
chapter_id=chapter_id
)
await db.commit()
return {
"success": True,
"message": f"已删除{len(memories)}条记忆"
}
except Exception as e:
logger.error(f"❌ 删除记忆失败: {str(e)}")
await db.rollback()
raise HTTPException(status_code=500, detail=str(e))
+62 -12
View File
@@ -1,5 +1,5 @@
"""大纲管理API"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, delete
from typing import List, AsyncGenerator, Dict, Any
@@ -21,6 +21,7 @@ from app.schemas.outline import (
)
from app.services.ai_service import AIService
from app.services.prompt_service import prompt_service
from app.services.memory_service import memory_service
from app.logger import get_logger
from app.api.settings import get_user_ai_service
from app.utils.sse_response import SSEResponse, create_sse_response
@@ -328,6 +329,7 @@ async def reorder_outlines(
@router.post("/generate", response_model=OutlineListResponse, summary="AI生成/续写大纲")
async def generate_outline(
request: OutlineGenerateRequest,
http_request: Request,
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
):
@@ -377,8 +379,10 @@ async def generate_outline(
detail="续写模式需要已有大纲,当前项目没有大纲"
)
# 获取用户ID用于记忆检索
user_id = getattr(http_request.state, "user_id", "system")
return await _continue_outline(
request, project, existing_outlines, db, user_ai_service
request, project, existing_outlines, db, user_ai_service, user_id
)
else:
@@ -478,9 +482,10 @@ async def _continue_outline(
project: Project,
existing_outlines: List[Outline],
db: AsyncSession,
user_ai_service: AIService
user_ai_service: AIService,
user_id: str = "system"
) -> OutlineListResponse:
"""续写大纲 - 分批生成,每批5章"""
"""续写大纲 - 分批生成,每批5章(记忆增强版)"""
logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)}")
# 分析已有大纲
@@ -545,7 +550,25 @@ async def _continue_outline(
for o in latest_outlines
])
# 使用标准续写提示词模板
# 🧠 构建记忆增强上下文(仅续写模式需要)
memory_context = None
try:
logger.info(f"🧠 为第{batch_num + 1}批构建记忆上下文...")
# 使用最近一章的大纲作为查询
query_outline = recent_outlines[-1].content if recent_outlines else ""
memory_context = await memory_service.build_context_for_generation(
user_id=user_id,
project_id=project.id,
current_chapter=current_start_chapter,
chapter_outline=query_outline,
character_names=[c.name for c in characters] if characters else None
)
logger.info(f"✅ 记忆上下文构建完成: {memory_context['stats']}")
except Exception as e:
logger.warning(f"⚠️ 记忆上下文构建失败,继续不使用记忆: {str(e)}")
memory_context = None
# 使用标准续写提示词模板(支持记忆增强)
prompt = prompt_service.get_outline_continue_prompt(
title=project.title,
theme=request.theme or project.theme or "未设定",
@@ -563,7 +586,8 @@ async def _continue_outline(
plot_stage_instruction=stage_instruction,
start_chapter=current_start_chapter,
story_direction=request.story_direction or "自然延续",
requirements=request.requirements or ""
requirements=request.requirements or "",
memory_context=memory_context
)
# 调用AI生成当前批次
@@ -834,9 +858,10 @@ async def new_outline_generator(
async def continue_outline_generator(
data: Dict[str, Any],
db: AsyncSession,
user_ai_service: AIService
user_ai_service: AIService,
user_id: str = "system"
) -> AsyncGenerator[str, None]:
"""大纲续写SSE生成器 - 分批生成,推送进度"""
"""大纲续写SSE生成器 - 分批生成,推送进度(记忆增强版)"""
db_committed = False
try:
yield await SSEResponse.send_progress("开始续写大纲...", 5)
@@ -940,12 +965,32 @@ async def continue_outline_generator(
for o in latest_outlines
])
# 🧠 构建记忆增强上下文
memory_context = None
try:
yield await SSEResponse.send_progress(
f"🧠 构建记忆上下文...",
batch_progress + 3
)
query_outline = recent_outlines[-1].content if recent_outlines else ""
memory_context = await memory_service.build_context_for_generation(
user_id=user_id,
project_id=project_id,
current_chapter=current_start_chapter,
chapter_outline=query_outline,
character_names=[c.name for c in characters] if characters else None
)
logger.info(f"✅ 记忆上下文: {memory_context['stats']}")
except Exception as e:
logger.warning(f"⚠️ 记忆上下文构建失败: {str(e)}")
memory_context = None
yield await SSEResponse.send_progress(
f"🤖 调用AI生成第{str(batch_num + 1)}批...",
f" 调用AI生成第{str(batch_num + 1)}批...",
batch_progress + 5
)
# 使用标准续写提示词模板
# 使用标准续写提示词模板(支持记忆增强)
prompt = prompt_service.get_outline_continue_prompt(
title=project.title,
theme=data.get("theme") or project.theme or "未设定",
@@ -963,7 +1008,8 @@ async def continue_outline_generator(
plot_stage_instruction=stage_instruction,
start_chapter=current_start_chapter,
story_direction=data.get("story_direction", "自然延续"),
requirements=data.get("requirements", "")
requirements=data.get("requirements", ""),
memory_context=memory_context
)
# 调用AI生成当前批次
@@ -1062,6 +1108,7 @@ async def continue_outline_generator(
@router.post("/generate-stream", summary="AI生成/续写大纲(SSE流式)")
async def generate_outline_stream(
data: Dict[str, Any],
request: Request,
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
):
@@ -1111,6 +1158,9 @@ async def generate_outline_stream(
mode = "continue" if existing_outlines else "new"
logger.info(f"自动判断模式:{'续写' if existing_outlines else '新建'}")
# 获取用户ID
user_id = getattr(request.state, "user_id", "system")
# 根据模式选择生成器
if mode == "new":
return create_sse_response(new_outline_generator(data, db, user_ai_service))
@@ -1120,7 +1170,7 @@ async def generate_outline_stream(
status_code=400,
detail="续写模式需要已有大纲,当前项目没有大纲"
)
return create_sse_response(continue_outline_generator(data, db, user_ai_service))
return create_sse_response(continue_outline_generator(data, db, user_ai_service, user_id))
else:
raise HTTPException(
status_code=400,
+174 -2
View File
@@ -1,9 +1,11 @@
"""项目管理API"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, delete
from typing import List
import json
from urllib.parse import quote
from app.database import get_db
from app.models.project import Project
from app.models.character import Character
@@ -17,6 +19,12 @@ from app.schemas.project import (
ProjectResponse,
ProjectListResponse
)
from app.schemas.import_export import (
ExportOptions,
ImportValidationResult,
ImportResult
)
from app.services.import_export_service import ImportExportService
from app.logger import get_logger
from app.utils.data_consistency import (
run_full_data_consistency_check,
@@ -412,4 +420,168 @@ async def fix_project_member_counts(
raise
except Exception as e:
logger.error(f"修复成员计数失败: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"修复失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"修复失败: {str(e)}")
@router.post("/{project_id}/export-data", summary="导出项目数据为JSON")
async def export_project_data(
project_id: str,
options: ExportOptions,
db: AsyncSession = Depends(get_db)
):
"""
导出项目完整数据为JSON格式
Args:
project_id: 项目ID
options: 导出选项
Returns:
JSON文件下载
"""
try:
logger.info(f"开始导出项目数据: {project_id}")
# 检查项目是否存在
result = await db.execute(
select(Project).where(Project.id == project_id)
)
project = result.scalar_one_or_none()
if not project:
logger.warning(f"项目不存在: {project_id}")
raise HTTPException(status_code=404, detail="项目不存在")
# 导出数据
export_data = await ImportExportService.export_project(
project_id=project_id,
db=db,
include_generation_history=options.include_generation_history,
include_writing_styles=options.include_writing_styles
)
# 转换为JSON
json_content = export_data.model_dump_json(indent=2, exclude_none=True, by_alias=True)
# 生成文件名
safe_title = "".join(c for c in project.title if c.isalnum() or c in (' ', '-', '_'))
from datetime import datetime
date_str = datetime.now().strftime("%Y%m%d")
filename = f"project_{safe_title}_{date_str}.json"
encoded_filename = quote(filename)
logger.info(f"项目数据导出成功: {filename}")
return Response(
content=json_content.encode('utf-8'),
media_type="application/json; charset=utf-8",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
"Content-Type": "application/json; charset=utf-8"
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"导出项目数据失败: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}")
@router.post("/validate-import", response_model=ImportValidationResult, summary="验证导入文件")
async def validate_import_file(
file: UploadFile = File(...)
):
"""
验证导入文件的格式和内容
Args:
file: 上传的JSON文件
Returns:
验证结果
"""
try:
logger.info(f"验证导入文件: {file.filename}")
# 检查文件类型
if not file.filename.endswith('.json'):
raise HTTPException(status_code=400, detail="只支持JSON格式文件")
# 读取文件内容
content = await file.read()
# 检查文件大小(50MB限制)
max_size = 50 * 1024 * 1024 # 50MB
if len(content) > max_size:
raise HTTPException(status_code=413, detail="文件大小超过50MB限制")
# 解析JSON
try:
data = json.loads(content.decode('utf-8'))
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"无效的JSON格式: {str(e)}")
# 验证数据
validation_result = ImportExportService.validate_import_data(data)
logger.info(f"文件验证完成: valid={validation_result.valid}")
return validation_result
except HTTPException:
raise
except Exception as e:
logger.error(f"验证导入文件失败: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"验证失败: {str(e)}")
@router.post("/import", response_model=ImportResult, summary="导入项目")
async def import_project(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db)
):
"""
导入项目数据(创建新项目)
Args:
file: 上传的JSON文件
Returns:
导入结果
"""
try:
logger.info(f"开始导入项目: {file.filename}")
# 检查文件类型
if not file.filename.endswith('.json'):
raise HTTPException(status_code=400, detail="只支持JSON格式文件")
# 读取文件内容
content = await file.read()
# 检查文件大小
max_size = 50 * 1024 * 1024 # 50MB
if len(content) > max_size:
raise HTTPException(status_code=413, detail="文件大小超过50MB限制")
# 解析JSON
try:
data = json.loads(content.decode('utf-8'))
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"无效的JSON格式: {str(e)}")
# 导入数据
import_result = await ImportExportService.import_project(data, db)
if import_result.success:
logger.info(f"项目导入成功: {import_result.project_id}")
else:
logger.warning(f"项目导入失败: {import_result.message}")
return import_result
except HTTPException:
raise
except Exception as e:
logger.error(f"导入项目失败: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"导入失败: {str(e)}")
+8 -8
View File
@@ -28,7 +28,7 @@ def read_env_defaults() -> Dict[str, Any]:
"api_provider": app_settings.default_ai_provider,
"api_key": app_settings.openai_api_key or app_settings.anthropic_api_key or "",
"api_base_url": app_settings.openai_base_url or app_settings.anthropic_base_url or "",
"model_name": app_settings.default_model,
"llm_model": app_settings.default_model,
"temperature": app_settings.default_temperature,
"max_tokens": app_settings.default_max_tokens,
}
@@ -71,7 +71,7 @@ async def get_user_ai_service(
api_provider=settings.api_provider,
api_key=settings.api_key,
api_base_url=settings.api_base_url or "",
model_name=settings.model_name,
model_name=settings.llm_model,
temperature=settings.temperature,
max_tokens=settings.max_tokens
)
@@ -305,7 +305,7 @@ class ApiTestRequest(BaseModel):
api_key: str
api_base_url: str
provider: str
model_name: str
llm_model: str
@router.post("/test")
@@ -322,7 +322,7 @@ async def test_api_connection(data: ApiTestRequest):
api_key = data.api_key
api_base_url = data.api_base_url
provider = data.provider
model_name = data.model_name
llm_model = data.llm_model
import time
try:
@@ -333,7 +333,7 @@ async def test_api_connection(data: ApiTestRequest):
api_provider=provider,
api_key=api_key,
api_base_url=api_base_url,
default_model=model_name,
default_model=llm_model,
default_temperature=0.7,
default_max_tokens=100
)
@@ -343,13 +343,13 @@ async def test_api_connection(data: ApiTestRequest):
logger.info(f"🧪 开始测试 API 连接")
logger.info(f" - 提供商: {provider}")
logger.info(f" - 模型: {model_name}")
logger.info(f" - 模型: {llm_model}")
logger.info(f" - Base URL: {api_base_url}")
response = await test_service.generate_text(
prompt=test_prompt,
provider=provider,
model=model_name,
model=llm_model,
temperature=0.7,
max_tokens=8000
)
@@ -366,7 +366,7 @@ async def test_api_connection(data: ApiTestRequest):
"message": "API 连接测试成功",
"response_time_ms": response_time,
"provider": provider,
"model": model_name,
"model": llm_model,
"response_preview": response[:100] if response and len(response) > 100 else response,
"details": {
"api_available": True,
+9
View File
@@ -15,6 +15,15 @@ logger = get_logger(__name__)
# 创建基类
Base = declarative_base()
# 导入所有模型,确保 Base.metadata 能够发现它们
# 这必须在 Base 创建之后、init_db 之前导入
from app.models import (
Project, Outline, Character, Chapter, GenerationHistory,
Settings, WritingStyle, ProjectDefaultStyle,
RelationshipType, CharacterRelationship, Organization, OrganizationMember,
StoryMemory, PlotAnalysis, AnalysisTask
)
# 引擎缓存:每个用户一个引擎
_engine_cache: Dict[str, Any] = {}
+2 -1
View File
@@ -114,7 +114,7 @@ async def db_session_stats():
from app.api import (
projects, outlines, characters, chapters,
wizard_stream, relationships, organizations,
auth, users, settings, writing_styles
auth, users, settings, writing_styles, memories
)
app.include_router(auth.router, prefix="/api")
@@ -129,6 +129,7 @@ app.include_router(chapters.router, prefix="/api")
app.include_router(relationships.router, prefix="/api")
app.include_router(organizations.router, prefix="/api")
app.include_router(writing_styles.router, prefix="/api")
app.include_router(memories.router) # 记忆管理API (已包含/api前缀)
static_dir = Path(__file__).parent.parent / "static"
if static_dir.exists():
+5
View File
@@ -13,6 +13,8 @@ from app.models.relationship import (
Organization,
OrganizationMember
)
from app.models.memory import StoryMemory, PlotAnalysis
from app.models.analysis_task import AnalysisTask
__all__ = [
"Project",
@@ -27,4 +29,7 @@ __all__ = [
"CharacterRelationship",
"Organization",
"OrganizationMember",
"StoryMemory",
"PlotAnalysis",
"AnalysisTask",
]
+38
View File
@@ -0,0 +1,38 @@
"""分析任务模型 - 追踪异步章节分析任务状态"""
from sqlalchemy import Column, String, Integer, Text, DateTime, ForeignKey, Index
from sqlalchemy.sql import func
from app.database import Base
import uuid
class AnalysisTask(Base):
"""
分析任务表 - 追踪异步分析任务的执行状态
状态流转: pending -> running -> completed/failed
"""
__tablename__ = "analysis_tasks"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="任务ID")
chapter_id = Column(String(36), ForeignKey('chapters.id', ondelete='CASCADE'), nullable=False, comment="章节ID")
user_id = Column(String(50), nullable=False, comment="用户ID")
project_id = Column(String(36), nullable=False, comment="项目ID")
# 任务状态
status = Column(String(20), nullable=False, default='pending', comment="任务状态: pending/running/completed/failed")
progress = Column(Integer, default=0, comment="进度 0-100")
error_message = Column(Text, nullable=True, comment="错误信息")
# 时间戳
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
started_at = Column(DateTime, nullable=True, comment="开始执行时间")
completed_at = Column(DateTime, nullable=True, comment="完成时间")
# 索引优化查询
__table_args__ = (
Index('idx_chapter_id_created', 'chapter_id', 'created_at'),
Index('idx_status', 'status'),
)
def __repr__(self):
return f"<AnalysisTask(id={self.id[:8]}..., chapter_id={self.chapter_id[:8]}..., status={self.status})>"
+200
View File
@@ -0,0 +1,200 @@
"""长期记忆数据模型 - 支持向量检索和剧情分析"""
from sqlalchemy import Column, String, Text, Integer, DateTime, ForeignKey, Float, JSON, Boolean
from sqlalchemy.sql import func
from app.database import Base
import uuid
class StoryMemory(Base):
"""故事记忆表 - 存储结构化的故事片段和元数据"""
__tablename__ = "story_memories"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
chapter_id = Column(String(36), ForeignKey("chapters.id", ondelete="CASCADE"), nullable=True, index=True)
# 记忆类型
memory_type = Column(String(50), nullable=False, index=True, comment="""
记忆类型:
- plot_point: 情节点
- character_event: 角色事件
- world_detail: 世界观细节
- hook: 钩子(悬念/冲突)
- foreshadow: 伏笔
- dialogue: 重要对话
- scene: 场景描写
""")
# 记忆内容
title = Column(String(200), comment="记忆标题/简述")
content = Column(Text, nullable=False, comment="记忆内容摘要(100-500字)")
full_context = Column(Text, comment="完整上下文(可选,用于详细记录)")
# 关联信息
related_characters = Column(JSON, comment="涉及角色ID列表: ['char_id_1', 'char_id_2']")
related_locations = Column(JSON, comment="涉及地点列表: ['地点1', '地点2']")
tags = Column(JSON, comment="标签列表: ['悬念', '转折', '伏笔', '高潮']")
# 重要性评分 (用于过滤和排序)
importance_score = Column(Float, default=0.5, comment="重要性评分 0.0-1.0")
# 时间线定位
story_timeline = Column(Integer, nullable=False, index=True, comment="故事时间线位置(章节序号)")
chapter_position = Column(Integer, default=0, comment="章节内位置(字符位置)")
text_length = Column(Integer, default=0, comment="文本长度(字符数)")
# 伏笔相关字段
is_foreshadow = Column(Integer, default=0, comment="伏笔状态: 0=普通记忆, 1=已埋下伏笔, 2=伏笔已回收")
foreshadow_resolved_at = Column(String(36), ForeignKey("chapters.id", ondelete="SET NULL"), comment="伏笔回收的章节ID")
foreshadow_strength = Column(Float, comment="伏笔强度 0.0-1.0")
# 向量数据库关联
vector_id = Column(String(100), unique=True, comment="向量数据库中的唯一ID")
embedding_model = Column(String(100), default="paraphrase-multilingual-MiniLM-L12-v2", comment="使用的embedding模型")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
def __repr__(self):
return f"<StoryMemory(id={self.id[:8]}, type={self.memory_type}, title={self.title})>"
def to_dict(self):
"""转换为字典格式"""
return {
"id": self.id,
"project_id": self.project_id,
"chapter_id": self.chapter_id,
"memory_type": self.memory_type,
"title": self.title,
"content": self.content,
"related_characters": self.related_characters,
"related_locations": self.related_locations,
"tags": self.tags,
"importance_score": self.importance_score,
"story_timeline": self.story_timeline,
"is_foreshadow": self.is_foreshadow,
"created_at": self.created_at.isoformat() if self.created_at else None
}
class PlotAnalysis(Base):
"""剧情分析表 - 存储AI分析的章节结构和剧情元素"""
__tablename__ = "plot_analysis"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
chapter_id = Column(String(36), ForeignKey("chapters.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
# 剧情结构分析
plot_stage = Column(String(50), comment="剧情阶段: 开端/发展/高潮/结局/过渡")
conflict_level = Column(Integer, comment="冲突强度 1-10")
conflict_types = Column(JSON, comment="冲突类型列表: ['人与人', '人与己', '人与环境']")
# 情感分析
emotional_tone = Column(String(100), comment="主导情感: 紧张/温馨/悲伤/激昂/平静")
emotional_intensity = Column(Float, comment="情感强度 0.0-1.0")
emotional_curve = Column(JSON, comment="情感曲线: {start: 0.3, middle: 0.7, end: 0.5}")
# 钩子分析 (Hook Analysis)
hooks = Column(JSON, comment="""钩子列表 - 吸引读者的元素: [
{
"type": "悬念|情感|冲突|认知",
"content": "具体内容",
"strength": 8,
"position": "开头|中段|结尾"
}
]""")
hooks_count = Column(Integer, default=0, comment="钩子数量")
hooks_avg_strength = Column(Float, comment="钩子平均强度")
# 伏笔分析 (Foreshadowing Analysis)
foreshadows = Column(JSON, comment="""伏笔列表: [
{
"content": "伏笔内容",
"type": "planted|resolved",
"strength": 7,
"subtlety": 8,
"reference_chapter": 3
}
]""")
foreshadows_planted = Column(Integer, default=0, comment="本章埋下的伏笔数量")
foreshadows_resolved = Column(Integer, default=0, comment="本章回收的伏笔数量")
# 关键情节点 (Plot Points)
plot_points = Column(JSON, comment="""情节点列表: [
{
"content": "情节点描述",
"importance": 0.9,
"type": "revelation|conflict|resolution|transition",
"impact": "对故事的影响描述"
}
]""")
plot_points_count = Column(Integer, default=0, comment="情节点数量")
# 角色状态追踪 (Character State Tracking)
character_states = Column(JSON, comment="""角色状态变化: [
{
"character_id": "xxx",
"character_name": "张三",
"state_before": "犹豫不决",
"state_after": "坚定信念",
"psychological_change": "内心描述",
"key_event": "触发事件",
"relationship_changes": {"李四": "关系变化"}
}
]""")
# 场景和氛围
scenes = Column(JSON, comment="场景列表: [{location: '地点', atmosphere: '氛围', duration: '时长'}]")
pacing = Column(String(50), comment="节奏: slow|moderate|fast|varied")
# 质量评分
overall_quality_score = Column(Float, comment="整体质量评分 0.0-10.0")
pacing_score = Column(Float, comment="节奏评分 0.0-10.0")
engagement_score = Column(Float, comment="吸引力评分 0.0-10.0")
coherence_score = Column(Float, comment="连贯性评分 0.0-10.0")
# 文本分析报告
analysis_report = Column(Text, comment="完整的文字分析报告")
suggestions = Column(JSON, comment="改进建议列表: ['建议1', '建议2']")
# 统计信息
word_count = Column(Integer, comment="章节字数")
dialogue_ratio = Column(Float, comment="对话占比 0.0-1.0")
description_ratio = Column(Float, comment="描写占比 0.0-1.0")
created_at = Column(DateTime, server_default=func.now(), comment="分析时间")
def __repr__(self):
return f"<PlotAnalysis(chapter_id={self.chapter_id[:8]}, stage={self.plot_stage}, quality={self.overall_quality_score})>"
def to_dict(self):
"""转换为字典格式"""
return {
"id": self.id,
"chapter_id": self.chapter_id,
"plot_stage": self.plot_stage,
"conflict_level": self.conflict_level,
"conflict_types": self.conflict_types or [],
"emotional_tone": self.emotional_tone,
"emotional_intensity": self.emotional_intensity or 0.0,
"hooks": self.hooks or [],
"hooks_count": self.hooks_count or 0,
"foreshadows": self.foreshadows or [],
"foreshadows_planted": self.foreshadows_planted or 0,
"foreshadows_resolved": self.foreshadows_resolved or 0,
"plot_points": self.plot_points or [],
"plot_points_count": self.plot_points_count or 0,
"character_states": self.character_states or [],
"scenes": self.scenes or [],
"pacing": self.pacing,
"overall_quality_score": self.overall_quality_score or 0.0,
"pacing_score": self.pacing_score or 0.0,
"engagement_score": self.engagement_score or 0.0,
"coherence_score": self.coherence_score or 0.0,
"analysis_report": self.analysis_report,
"suggestions": self.suggestions or [],
"dialogue_ratio": self.dialogue_ratio or 0.0,
"description_ratio": self.description_ratio or 0.0,
"created_at": self.created_at.isoformat() if self.created_at else None
}
+1 -1
View File
@@ -14,7 +14,7 @@ class Settings(Base):
api_provider = Column(String(50), default="openai", comment="API提供商")
api_key = Column(String(500), comment="API密钥")
api_base_url = Column(String(500), comment="自定义API地址")
model_name = Column(String(100), default="gpt-4", comment="模型名称")
llm_model = Column(String(100), default="gpt-4", comment="模型名称")
temperature = Column(Float, default=0.7, comment="温度参数")
max_tokens = Column(Integer, default=2000, comment="最大token数")
preferences = Column(Text, comment="其他偏好设置(JSON)")
+136
View File
@@ -0,0 +1,136 @@
"""导入导出相关的Pydantic模型"""
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
class ExportOptions(BaseModel):
"""导出选项"""
include_generation_history: bool = Field(False, description="是否包含生成历史")
include_writing_styles: bool = Field(True, description="是否包含写作风格")
class ChapterExportData(BaseModel):
"""章节导出数据"""
title: str
content: Optional[str] = None
summary: Optional[str] = None
chapter_number: int
word_count: int = 0
status: str = "draft"
created_at: Optional[str] = None
class CharacterExportData(BaseModel):
"""角色导出数据"""
name: str
age: Optional[str] = None
gender: Optional[str] = None
is_organization: bool = False
role_type: Optional[str] = None
personality: Optional[str] = None
background: Optional[str] = None
appearance: Optional[str] = None
traits: Optional[List[str]] = None
organization_type: Optional[str] = None
organization_purpose: Optional[str] = None
created_at: Optional[str] = None
class OutlineExportData(BaseModel):
"""大纲导出数据"""
title: str
content: Optional[str] = None
structure: Optional[str] = None
order_index: Optional[int] = None
created_at: Optional[str] = None
class RelationshipExportData(BaseModel):
"""关系导出数据"""
source_name: str
target_name: str
relationship_name: Optional[str] = None
intimacy_level: int = 50
status: str = "active"
description: Optional[str] = None
started_at: Optional[str] = None
class OrganizationExportData(BaseModel):
"""组织详情导出数据"""
character_name: str
parent_org_name: Optional[str] = None
power_level: int = 50
member_count: int = 0
location: Optional[str] = None
motto: Optional[str] = None
color: Optional[str] = None
class OrganizationMemberExportData(BaseModel):
"""组织成员导出数据"""
organization_name: str
character_name: str
position: str
rank: int = 0
status: str = "active"
joined_at: Optional[str] = None
loyalty: int = 50
contribution: int = 0
notes: Optional[str] = None
class WritingStyleExportData(BaseModel):
"""写作风格导出数据"""
name: str
style_type: str
preset_id: Optional[str] = None
description: Optional[str] = None
prompt_content: str
order_index: int = 0
class GenerationHistoryExportData(BaseModel):
"""生成历史导出数据"""
chapter_title: Optional[str] = None
prompt: Optional[str] = None
generated_content: Optional[str] = None
model: Optional[str] = None
tokens_used: Optional[int] = None
generation_time: Optional[float] = None
created_at: Optional[str] = None
class ProjectExportData(BaseModel):
"""项目完整导出数据"""
version: str = "1.0.0"
export_time: str
project: Dict[str, Any]
chapters: List[ChapterExportData] = []
characters: List[CharacterExportData] = []
outlines: List[OutlineExportData] = []
relationships: List[RelationshipExportData] = []
organizations: List[OrganizationExportData] = []
organization_members: List[OrganizationMemberExportData] = []
writing_styles: List[WritingStyleExportData] = []
generation_history: List[GenerationHistoryExportData] = []
class ImportValidationResult(BaseModel):
"""导入验证结果"""
valid: bool
version: str
project_name: Optional[str] = None
statistics: Dict[str, int] = {}
errors: List[str] = []
warnings: List[str] = []
class ImportResult(BaseModel):
"""导入结果"""
success: bool
project_id: Optional[str] = None
message: str
statistics: Dict[str, int] = {}
warnings: List[str] = []
+1 -1
View File
@@ -11,7 +11,7 @@ class SettingsBase(BaseModel):
api_provider: Optional[str] = Field(default="openai", description="API提供商")
api_key: Optional[str] = Field(default=None, description="API密钥")
api_base_url: Optional[str] = Field(default=None, description="自定义API地址")
model_name: Optional[str] = Field(default="gpt-4", description="模型名称")
llm_model: Optional[str] = Field(default="gpt-4", description="模型名称")
temperature: Optional[float] = Field(default=0.7, ge=0.0, le=2.0, description="温度参数")
max_tokens: Optional[int] = Field(default=2000, ge=1, description="最大token数")
preferences: Optional[str] = Field(default=None, description="其他偏好设置(JSON)")
@@ -0,0 +1,769 @@
"""导入导出服务"""
import json
from datetime import datetime
from typing import Dict, List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.project import Project
from app.models.chapter import Chapter
from app.models.character import Character
from app.models.outline import Outline
from app.models.relationship import CharacterRelationship, Organization, OrganizationMember
from app.models.writing_style import WritingStyle
from app.models.generation_history import GenerationHistory
from app.schemas.import_export import (
ProjectExportData,
ChapterExportData,
CharacterExportData,
OutlineExportData,
RelationshipExportData,
OrganizationExportData,
OrganizationMemberExportData,
WritingStyleExportData,
GenerationHistoryExportData,
ImportValidationResult,
ImportResult
)
from app.logger import get_logger
logger = get_logger(__name__)
class ImportExportService:
"""导入导出服务类"""
SUPPORTED_VERSION = "1.0.0"
@staticmethod
async def export_project(
project_id: str,
db: AsyncSession,
include_generation_history: bool = False,
include_writing_styles: bool = True
) -> ProjectExportData:
"""
导出项目完整数据
Args:
project_id: 项目ID
db: 数据库会话
include_generation_history: 是否包含生成历史
include_writing_styles: 是否包含写作风格
Returns:
ProjectExportData: 导出的项目数据
"""
logger.info(f"开始导出项目: {project_id}")
# 获取项目基本信息
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise ValueError(f"项目不存在: {project_id}")
# 项目基本信息
project_data = {
"title": project.title,
"description": project.description,
"theme": project.theme,
"genre": project.genre,
"target_words": project.target_words,
"current_words": project.current_words,
"status": project.status,
"world_time_period": project.world_time_period,
"world_location": project.world_location,
"world_atmosphere": project.world_atmosphere,
"world_rules": project.world_rules,
"chapter_count": project.chapter_count,
"narrative_perspective": project.narrative_perspective,
"character_count": project.character_count,
"created_at": project.created_at.isoformat() if project.created_at else None,
}
# 导出章节
chapters = await ImportExportService._export_chapters(project_id, db)
logger.info(f"导出章节数: {len(chapters)}")
# 导出角色
characters = await ImportExportService._export_characters(project_id, db)
logger.info(f"导出角色数: {len(characters)}")
# 导出大纲
outlines = await ImportExportService._export_outlines(project_id, db)
logger.info(f"导出大纲数: {len(outlines)}")
# 导出关系
relationships = await ImportExportService._export_relationships(project_id, db)
logger.info(f"导出关系数: {len(relationships)}")
# 导出组织详情
organizations = await ImportExportService._export_organizations(project_id, db)
logger.info(f"导出组织数: {len(organizations)}")
# 导出组织成员
org_members = await ImportExportService._export_organization_members(project_id, db)
logger.info(f"导出组织成员数: {len(org_members)}")
# 导出写作风格(可选)
writing_styles = []
if include_writing_styles:
writing_styles = await ImportExportService._export_writing_styles(project_id, db)
logger.info(f"导出写作风格数: {len(writing_styles)}")
# 导出生成历史(可选)
generation_history = []
if include_generation_history:
generation_history = await ImportExportService._export_generation_history(project_id, db)
logger.info(f"导出生成历史数: {len(generation_history)}")
export_data = ProjectExportData(
version=ImportExportService.SUPPORTED_VERSION,
export_time=datetime.utcnow().isoformat(),
project=project_data,
chapters=chapters,
characters=characters,
outlines=outlines,
relationships=relationships,
organizations=organizations,
organization_members=org_members,
writing_styles=writing_styles,
generation_history=generation_history
)
logger.info(f"项目导出完成: {project_id}")
return export_data
@staticmethod
async def _export_chapters(project_id: str, db: AsyncSession) -> List[ChapterExportData]:
"""导出章节"""
result = await db.execute(
select(Chapter)
.where(Chapter.project_id == project_id)
.order_by(Chapter.chapter_number)
)
chapters = result.scalars().all()
return [
ChapterExportData(
title=ch.title,
content=ch.content,
summary=ch.summary,
chapter_number=ch.chapter_number,
word_count=ch.word_count or 0,
status=ch.status,
created_at=ch.created_at.isoformat() if ch.created_at else None
)
for ch in chapters
]
@staticmethod
async def _export_characters(project_id: str, db: AsyncSession) -> List[CharacterExportData]:
"""导出角色"""
result = await db.execute(
select(Character).where(Character.project_id == project_id)
)
characters = result.scalars().all()
exported = []
for char in characters:
# 解析traits JSON
traits = None
if char.traits:
try:
traits = json.loads(char.traits) if isinstance(char.traits, str) else char.traits
except:
traits = None
exported.append(CharacterExportData(
name=char.name,
age=char.age,
gender=char.gender,
is_organization=char.is_organization or False,
role_type=char.role_type,
personality=char.personality,
background=char.background,
appearance=char.appearance,
traits=traits,
organization_type=char.organization_type,
organization_purpose=char.organization_purpose,
created_at=char.created_at.isoformat() if char.created_at else None
))
return exported
@staticmethod
async def _export_outlines(project_id: str, db: AsyncSession) -> List[OutlineExportData]:
"""导出大纲"""
result = await db.execute(
select(Outline)
.where(Outline.project_id == project_id)
.order_by(Outline.order_index)
)
outlines = result.scalars().all()
return [
OutlineExportData(
title=ol.title,
content=ol.content,
structure=ol.structure,
order_index=ol.order_index,
created_at=ol.created_at.isoformat() if ol.created_at else None
)
for ol in outlines
]
@staticmethod
async def _export_relationships(project_id: str, db: AsyncSession) -> List[RelationshipExportData]:
"""导出关系"""
result = await db.execute(
select(CharacterRelationship, Character)
.join(Character, CharacterRelationship.character_from_id == Character.id)
.where(CharacterRelationship.project_id == project_id)
)
relationships = result.all()
exported = []
for rel, char_from in relationships:
# 获取目标角色名称
target_result = await db.execute(
select(Character).where(Character.id == rel.character_to_id)
)
char_to = target_result.scalar_one_or_none()
if char_to:
exported.append(RelationshipExportData(
source_name=char_from.name,
target_name=char_to.name,
relationship_name=rel.relationship_name,
intimacy_level=rel.intimacy_level or 50,
status=rel.status or "active",
description=rel.description,
started_at=rel.started_at
))
return exported
@staticmethod
async def _export_organizations(project_id: str, db: AsyncSession) -> List[OrganizationExportData]:
"""导出组织详情"""
result = await db.execute(
select(Organization, Character)
.join(Character, Organization.character_id == Character.id)
.where(Organization.project_id == project_id)
)
organizations = result.all()
exported = []
for org, char in organizations:
# 获取父组织名称
parent_name = None
if org.parent_org_id:
parent_result = await db.execute(
select(Organization, Character)
.join(Character, Organization.character_id == Character.id)
.where(Organization.id == org.parent_org_id)
)
parent_data = parent_result.first()
if parent_data:
parent_name = parent_data[1].name
exported.append(OrganizationExportData(
character_name=char.name,
parent_org_name=parent_name,
power_level=org.power_level or 50,
member_count=org.member_count or 0,
location=org.location,
motto=org.motto,
color=org.color
))
return exported
@staticmethod
async def _export_organization_members(project_id: str, db: AsyncSession) -> List[OrganizationMemberExportData]:
"""导出组织成员"""
result = await db.execute(
select(OrganizationMember, Organization, Character)
.join(Organization, OrganizationMember.organization_id == Organization.id)
.join(Character, Organization.character_id == Character.id)
.where(Organization.project_id == project_id)
)
members = result.all()
exported = []
for member, org, org_char in members:
# 获取成员角色名称
char_result = await db.execute(
select(Character).where(Character.id == member.character_id)
)
member_char = char_result.scalar_one_or_none()
if member_char:
exported.append(OrganizationMemberExportData(
organization_name=org_char.name,
character_name=member_char.name,
position=member.position,
rank=member.rank or 0,
status=member.status or "active",
joined_at=member.joined_at,
loyalty=member.loyalty or 50,
contribution=member.contribution or 0,
notes=member.notes
))
return exported
@staticmethod
async def _export_writing_styles(project_id: str, db: AsyncSession) -> List[WritingStyleExportData]:
"""导出写作风格"""
result = await db.execute(
select(WritingStyle)
.where(WritingStyle.project_id == project_id)
.order_by(WritingStyle.order_index)
)
styles = result.scalars().all()
return [
WritingStyleExportData(
name=style.name,
style_type=style.style_type,
preset_id=style.preset_id,
description=style.description,
prompt_content=style.prompt_content,
order_index=style.order_index or 0
)
for style in styles
]
@staticmethod
async def _export_generation_history(project_id: str, db: AsyncSession) -> List[GenerationHistoryExportData]:
"""导出生成历史"""
result = await db.execute(
select(GenerationHistory, Chapter)
.outerjoin(Chapter, GenerationHistory.chapter_id == Chapter.id)
.where(GenerationHistory.project_id == project_id)
.order_by(GenerationHistory.created_at.desc())
.limit(100) # 限制最多导出100条历史记录
)
histories = result.all()
return [
GenerationHistoryExportData(
chapter_title=chapter.title if chapter else None,
prompt=history.prompt,
generated_content=history.generated_content,
model=history.model,
tokens_used=history.tokens_used,
generation_time=history.generation_time,
created_at=history.created_at.isoformat() if history.created_at else None
)
for history, chapter in histories
]
@staticmethod
def validate_import_data(data: Dict) -> ImportValidationResult:
"""
验证导入数据
Args:
data: 导入的JSON数据
Returns:
ImportValidationResult: 验证结果
"""
errors = []
warnings = []
statistics = {}
# 检查版本
version = data.get("version", "")
if not version:
errors.append("缺少版本信息")
elif version != ImportExportService.SUPPORTED_VERSION:
warnings.append(f"版本不匹配: 导入文件版本为 {version}, 当前支持版本为 {ImportExportService.SUPPORTED_VERSION}")
# 检查必需字段
if "project" not in data:
errors.append("缺少项目信息")
else:
project = data["project"]
if not project.get("title"):
errors.append("项目标题不能为空")
# 统计数据
statistics = {
"chapters": len(data.get("chapters", [])),
"characters": len(data.get("characters", [])),
"outlines": len(data.get("outlines", [])),
"relationships": len(data.get("relationships", [])),
"organizations": len(data.get("organizations", [])),
"organization_members": len(data.get("organization_members", [])),
"writing_styles": len(data.get("writing_styles", [])),
"generation_history": len(data.get("generation_history", []))
}
# 检查数据完整性
if statistics["chapters"] == 0:
warnings.append("项目没有章节数据")
if statistics["characters"] == 0:
warnings.append("项目没有角色数据")
project_name = data.get("project", {}).get("title", "未知项目")
return ImportValidationResult(
valid=len(errors) == 0,
version=version,
project_name=project_name,
statistics=statistics,
errors=errors,
warnings=warnings
)
@staticmethod
async def import_project(
data: Dict,
db: AsyncSession
) -> ImportResult:
"""
导入项目数据(创建新项目)
Args:
data: 导入的JSON数据
db: 数据库会话
Returns:
ImportResult: 导入结果
"""
warnings = []
statistics = {}
try:
# 验证数据
validation = ImportExportService.validate_import_data(data)
if not validation.valid:
return ImportResult(
success=False,
message=f"数据验证失败: {', '.join(validation.errors)}",
statistics={},
warnings=validation.warnings
)
warnings.extend(validation.warnings)
logger.info(f"开始导入项目: {validation.project_name}")
# 创建项目
project_data = data["project"]
new_project = Project(
title=project_data.get("title"),
description=project_data.get("description"),
theme=project_data.get("theme"),
genre=project_data.get("genre"),
target_words=project_data.get("target_words"),
status=project_data.get("status", "planning"),
world_time_period=project_data.get("world_time_period"),
world_location=project_data.get("world_location"),
world_atmosphere=project_data.get("world_atmosphere"),
world_rules=project_data.get("world_rules"),
chapter_count=project_data.get("chapter_count"),
narrative_perspective=project_data.get("narrative_perspective"),
character_count=project_data.get("character_count"),
current_words=project_data.get("current_words", 0), # 保留原项目的字数
wizard_step=4, # 导入的项目设置为向导完成状态
wizard_status="completed" # 标记向导已完成
)
db.add(new_project)
await db.flush() # 获取project_id
logger.info(f"创建项目成功: {new_project.id}")
# 导入章节
chapters_count = await ImportExportService._import_chapters(
new_project.id, data.get("chapters", []), db
)
statistics["chapters"] = chapters_count
logger.info(f"导入章节数: {chapters_count}")
# 导入角色(包括组织)
char_mapping = await ImportExportService._import_characters(
new_project.id, data.get("characters", []), db
)
statistics["characters"] = len(char_mapping)
logger.info(f"导入角色数: {len(char_mapping)}")
# 导入大纲
outlines_count = await ImportExportService._import_outlines(
new_project.id, data.get("outlines", []), db
)
statistics["outlines"] = outlines_count
logger.info(f"导入大纲数: {outlines_count}")
# 导入关系
relationships_count = await ImportExportService._import_relationships(
new_project.id, data.get("relationships", []), char_mapping, db
)
statistics["relationships"] = relationships_count
logger.info(f"导入关系数: {relationships_count}")
# 导入组织详情
org_mapping = await ImportExportService._import_organizations(
new_project.id, data.get("organizations", []), char_mapping, db
)
statistics["organizations"] = len(org_mapping)
logger.info(f"导入组织数: {len(org_mapping)}")
# 导入组织成员
org_members_count = await ImportExportService._import_organization_members(
data.get("organization_members", []), char_mapping, org_mapping, db
)
statistics["organization_members"] = org_members_count
logger.info(f"导入组织成员数: {org_members_count}")
# 导入写作风格
styles_count = await ImportExportService._import_writing_styles(
new_project.id, data.get("writing_styles", []), db
)
statistics["writing_styles"] = styles_count
logger.info(f"导入写作风格数: {styles_count}")
# 提交事务
await db.commit()
logger.info(f"项目导入完成: {new_project.id}")
return ImportResult(
success=True,
project_id=new_project.id,
message="项目导入成功",
statistics=statistics,
warnings=warnings
)
except Exception as e:
await db.rollback()
logger.error(f"导入项目失败: {str(e)}", exc_info=True)
return ImportResult(
success=False,
message=f"导入失败: {str(e)}",
statistics=statistics,
warnings=warnings
)
@staticmethod
async def _import_chapters(
project_id: str,
chapters_data: List[Dict],
db: AsyncSession
) -> int:
"""导入章节"""
count = 0
for ch_data in chapters_data:
chapter = Chapter(
project_id=project_id,
title=ch_data.get("title"),
content=ch_data.get("content"),
summary=ch_data.get("summary"),
chapter_number=ch_data.get("chapter_number"),
word_count=ch_data.get("word_count", 0),
status=ch_data.get("status", "draft")
)
db.add(chapter)
count += 1
return count
@staticmethod
async def _import_characters(
project_id: str,
characters_data: List[Dict],
db: AsyncSession
) -> Dict[str, str]:
"""导入角色,返回名称到ID的映射"""
char_mapping = {}
for char_data in characters_data:
# 处理traits
traits = char_data.get("traits")
if traits and isinstance(traits, list):
traits = json.dumps(traits, ensure_ascii=False)
character = Character(
project_id=project_id,
name=char_data.get("name"),
age=char_data.get("age"),
gender=char_data.get("gender"),
is_organization=char_data.get("is_organization", False),
role_type=char_data.get("role_type"),
personality=char_data.get("personality"),
background=char_data.get("background"),
appearance=char_data.get("appearance"),
traits=traits,
organization_type=char_data.get("organization_type"),
organization_purpose=char_data.get("organization_purpose")
)
db.add(character)
await db.flush() # 获取ID
char_mapping[char_data.get("name")] = character.id
return char_mapping
@staticmethod
async def _import_outlines(
project_id: str,
outlines_data: List[Dict],
db: AsyncSession
) -> int:
"""导入大纲"""
count = 0
for ol_data in outlines_data:
outline = Outline(
project_id=project_id,
title=ol_data.get("title"),
content=ol_data.get("content"),
structure=ol_data.get("structure"),
order_index=ol_data.get("order_index")
)
db.add(outline)
count += 1
return count
@staticmethod
async def _import_relationships(
project_id: str,
relationships_data: List[Dict],
char_mapping: Dict[str, str],
db: AsyncSession
) -> int:
"""导入关系"""
count = 0
for rel_data in relationships_data:
source_name = rel_data.get("source_name")
target_name = rel_data.get("target_name")
# 查找角色ID
source_id = char_mapping.get(source_name)
target_id = char_mapping.get(target_name)
if source_id and target_id:
relationship = CharacterRelationship(
project_id=project_id,
character_from_id=source_id,
character_to_id=target_id,
relationship_name=rel_data.get("relationship_name"),
intimacy_level=rel_data.get("intimacy_level", 50),
status=rel_data.get("status", "active"),
description=rel_data.get("description"),
started_at=rel_data.get("started_at")
)
db.add(relationship)
count += 1
return count
@staticmethod
async def _import_organizations(
project_id: str,
organizations_data: List[Dict],
char_mapping: Dict[str, str],
db: AsyncSession
) -> Dict[str, str]:
"""导入组织详情,返回名称到ID的映射"""
org_mapping = {}
# 第一遍:创建所有组织(不设置父组织)
temp_orgs = []
for org_data in organizations_data:
char_name = org_data.get("character_name")
char_id = char_mapping.get(char_name)
if char_id:
organization = Organization(
project_id=project_id,
character_id=char_id,
power_level=org_data.get("power_level", 50),
member_count=org_data.get("member_count", 0),
location=org_data.get("location"),
motto=org_data.get("motto"),
color=org_data.get("color")
)
db.add(organization)
temp_orgs.append((organization, org_data.get("parent_org_name")))
await db.flush() # 获取所有组织的ID
# 建立名称到ID的映射
for org, _ in temp_orgs:
# 通过character_id查找角色名
result = await db.execute(
select(Character).where(Character.id == org.character_id)
)
char = result.scalar_one_or_none()
if char:
org_mapping[char.name] = org.id
# 第二遍:设置父组织关系
for org, parent_name in temp_orgs:
if parent_name:
parent_id = org_mapping.get(parent_name)
if parent_id:
org.parent_org_id = parent_id
return org_mapping
@staticmethod
async def _import_organization_members(
org_members_data: List[Dict],
char_mapping: Dict[str, str],
org_mapping: Dict[str, str],
db: AsyncSession
) -> int:
"""导入组织成员"""
count = 0
for member_data in org_members_data:
org_name = member_data.get("organization_name")
char_name = member_data.get("character_name")
org_id = org_mapping.get(org_name)
char_id = char_mapping.get(char_name)
if org_id and char_id:
member = OrganizationMember(
organization_id=org_id,
character_id=char_id,
position=member_data.get("position"),
rank=member_data.get("rank", 0),
status=member_data.get("status", "active"),
joined_at=member_data.get("joined_at"),
loyalty=member_data.get("loyalty", 50),
contribution=member_data.get("contribution", 0),
notes=member_data.get("notes")
)
db.add(member)
count += 1
return count
@staticmethod
async def _import_writing_styles(
project_id: str,
styles_data: List[Dict],
db: AsyncSession
) -> int:
"""导入写作风格"""
count = 0
for style_data in styles_data:
style = WritingStyle(
project_id=project_id,
name=style_data.get("name"),
style_type=style_data.get("style_type"),
preset_id=style_data.get("preset_id"),
description=style_data.get("description"),
prompt_content=style_data.get("prompt_content"),
order_index=style_data.get("order_index", 0)
)
db.add(style)
count += 1
return count
+782
View File
@@ -0,0 +1,782 @@
"""向量记忆服务 - 基于ChromaDB实现长期记忆和语义检索"""
import chromadb
from sentence_transformers import SentenceTransformer
from typing import List, Dict, Any, Optional
import json
from datetime import datetime
from app.logger import get_logger
import os
import hashlib
logger = get_logger(__name__)
# 配置模型缓存目录(不设置离线模式,让它自动选择)
# 如果本地有模型就用本地的,没有才联网下载
if 'SENTENCE_TRANSFORMERS_HOME' not in os.environ:
os.environ['SENTENCE_TRANSFORMERS_HOME'] = 'embedding'
class MemoryService:
"""向量记忆管理服务 - 实现语义检索和长期记忆"""
_instance = None
_initialized = False
def __new__(cls):
"""单例模式"""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""初始化ChromaDB和Embedding模型"""
if self._initialized:
return
try:
# 确保数据目录存在
chroma_dir = "data/chroma_db"
os.makedirs(chroma_dir, exist_ok=True)
# 初始化ChromaDB客户端(使用新API - PersistentClient)
self.client = chromadb.PersistentClient(path=chroma_dir)
# 初始化多语言embedding模型(支持中文)
logger.info("🔄 正在加载Embedding模型...")
# 确保模型缓存目录存在
model_cache_dir = 'embedding'
os.makedirs(model_cache_dir, exist_ok=True)
# 调试信息:打印环境变量和路径
logger.info(f"📂 当前工作目录: {os.getcwd()}")
logger.info(f"📂 模型缓存目录: {os.path.abspath(model_cache_dir)}")
logger.info(f"🔧 SENTENCE_TRANSFORMERS_HOME: {os.environ.get('SENTENCE_TRANSFORMERS_HOME', '未设置')}")
logger.info(f"🔧 TRANSFORMERS_OFFLINE: {os.environ.get('TRANSFORMERS_OFFLINE', '未设置')}")
logger.info(f"🔧 HF_HUB_OFFLINE: {os.environ.get('HF_HUB_OFFLINE', '未设置')}")
# 检查模型目录内容
if os.path.exists(model_cache_dir):
logger.info(f"📁 模型目录存在,检查内容...")
try:
items = os.listdir(model_cache_dir)
logger.info(f"📁 模型目录内容: {items}")
# 检查是否有预期的模型文件夹
expected_model_dir = os.path.join(model_cache_dir, 'models--sentence-transformers--paraphrase-multilingual-MiniLM-L12-v2')
if os.path.exists(expected_model_dir):
logger.info(f"✅ 找到本地模型目录: {expected_model_dir}")
# 检查快照目录
snapshots_dir = os.path.join(expected_model_dir, 'snapshots')
if os.path.exists(snapshots_dir):
snapshots = os.listdir(snapshots_dir)
logger.info(f"📁 模型快照: {snapshots}")
else:
logger.warning(f"⚠️ 未找到本地模型目录: {expected_model_dir}")
except Exception as e:
logger.error(f"❌ 检查模型目录失败: {str(e)}")
else:
logger.warning(f"⚠️ 模型目录不存在: {os.path.abspath(model_cache_dir)}")
try:
logger.info("🔄 尝试加载主模型: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
# 优先使用本地缓存的模型
# cache_folder会让模型优先从本地加载,只有不存在时才联网下载
# 注意:不要设置local_files_only=True,这会阻止fallback到联网下载
self.embedding_model = SentenceTransformer(
'sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2',
cache_folder=model_cache_dir,
device='cpu', # 明确指定使用CPU
trust_remote_code=False # 安全起见
)
logger.info("✅ Embedding模型加载成功 (paraphrase-multilingual-MiniLM-L12-v2)")
except Exception as e:
logger.warning(f"⚠️ 无法加载多语言模型: {str(e)}")
logger.error(f"❌ 详细错误: {repr(e)}")
import traceback
logger.error(f"❌ 错误堆栈:\n{traceback.format_exc()}")
logger.info("🔄 尝试使用备用模型: sentence-transformers/all-MiniLM-L6-v2")
try:
# 降级到更小的模型作为备选
self.embedding_model = SentenceTransformer(
'sentence-transformers/all-MiniLM-L6-v2',
cache_folder=model_cache_dir,
device='cpu',
trust_remote_code=False
)
logger.info("✅ 使用备用Embedding模型 (all-MiniLM-L6-v2)")
except Exception as e2:
logger.error(f"❌ 所有模型加载失败: {str(e2)}")
logger.error(f"❌ 详细错误: {repr(e2)}")
import traceback
logger.error(f"❌ 错误堆栈:\n{traceback.format_exc()}")
logger.error("💡 模型首次使用需要联网下载(约420MB)")
logger.error(" 或手动下载模型文件到 embedding 目录")
logger.error(f"💡 期望的模型目录结构:")
logger.error(f" {os.path.abspath(model_cache_dir)}/models--sentence-transformers--paraphrase-multilingual-MiniLM-L12-v2/")
raise RuntimeError("无法加载任何Embedding模型")
self._initialized = True
logger.info("✅ MemoryService初始化成功")
logger.info(f" - ChromaDB目录: {chroma_dir}")
logger.info(f" - Embedding模型: paraphrase-multilingual-MiniLM-L12-v2")
except Exception as e:
logger.error(f"❌ MemoryService初始化失败: {str(e)}")
raise
def get_collection(self, user_id: str, project_id: str):
"""
获取或创建项目的记忆集合
每个用户的每个项目有独立的collection,实现数据隔离
Args:
user_id: 用户ID
project_id: 项目ID
Returns:
ChromaDB Collection对象
"""
# ChromaDB collection命名规则:
# 1. 3-63字符(最重要!)
# 2. 开头和结尾必须是字母或数字
# 3. 只能包含字母、数字、下划线或短横线
# 4. 不能包含连续的点(..)
# 5. 不能是有效的IPv4地址
# 使用SHA256哈希压缩ID长度,确保不超过63字符
# 格式: u_{user_hash}_p_{project_hash} (约30字符)
user_hash = hashlib.sha256(user_id.encode()).hexdigest()[:8]
project_hash = hashlib.sha256(project_id.encode()).hexdigest()[:8]
collection_name = f"u_{user_hash}_p_{project_hash}"
try:
return self.client.get_or_create_collection(
name=collection_name,
metadata={
"user_id": user_id,
"project_id": project_id,
"created_at": datetime.now().isoformat()
}
)
except Exception as e:
logger.error(f"❌ 获取collection失败: {str(e)}")
raise
async def add_memory(
self,
user_id: str,
project_id: str,
memory_id: str,
content: str,
memory_type: str,
metadata: Dict[str, Any]
) -> bool:
"""
添加记忆到向量数据库
Args:
user_id: 用户ID
project_id: 项目ID
memory_id: 记忆唯一ID
content: 记忆内容(将被转换为向量)
memory_type: 记忆类型
metadata: 附加元数据
Returns:
是否添加成功
"""
try:
collection = self.get_collection(user_id, project_id)
# 生成文本的向量表示
embedding = self.embedding_model.encode(content).tolist()
# 准备元数据(ChromaDB要求所有值为基础类型)
chroma_metadata = {
"memory_type": memory_type,
"chapter_id": str(metadata.get("chapter_id", "")),
"chapter_number": int(metadata.get("chapter_number", 0)),
"importance": float(metadata.get("importance_score", 0.5)),
"tags": json.dumps(metadata.get("tags", []), ensure_ascii=False),
"title": str(metadata.get("title", ""))[:200], # 限制长度
"is_foreshadow": int(metadata.get("is_foreshadow", 0)),
"created_at": datetime.now().isoformat()
}
# 添加相关角色信息
if metadata.get("related_characters"):
chroma_metadata["related_characters"] = json.dumps(
metadata["related_characters"],
ensure_ascii=False
)
# 存储到向量库
collection.add(
ids=[memory_id],
embeddings=[embedding],
documents=[content],
metadatas=[chroma_metadata]
)
logger.info(f"✅ 记忆已添加: {memory_id[:8]}... (类型:{memory_type}, 重要性:{chroma_metadata['importance']})")
return True
except Exception as e:
logger.error(f"❌ 添加记忆失败: {str(e)}")
return False
async def batch_add_memories(
self,
user_id: str,
project_id: str,
memories: List[Dict[str, Any]]
) -> int:
"""
批量添加记忆(性能更好)
Args:
user_id: 用户ID
project_id: 项目ID
memories: 记忆列表,每个包含id、content、type、metadata
Returns:
成功添加的数量
"""
if not memories:
return 0
try:
collection = self.get_collection(user_id, project_id)
ids = []
documents = []
metadatas = []
embeddings = []
# 批量准备数据
for mem in memories:
ids.append(mem['id'])
documents.append(mem['content'])
# 生成embedding
embedding = self.embedding_model.encode(mem['content']).tolist()
embeddings.append(embedding)
# 准备元数据
metadata = mem.get('metadata', {})
chroma_metadata = {
"memory_type": mem['type'],
"chapter_id": str(metadata.get("chapter_id", "")),
"chapter_number": int(metadata.get("chapter_number", 0)),
"importance": float(metadata.get("importance_score", 0.5)),
"tags": json.dumps(metadata.get("tags", []), ensure_ascii=False),
"title": str(metadata.get("title", ""))[:200],
"is_foreshadow": int(metadata.get("is_foreshadow", 0)),
"created_at": datetime.now().isoformat()
}
metadatas.append(chroma_metadata)
# 批量添加
collection.add(
ids=ids,
embeddings=embeddings,
documents=documents,
metadatas=metadatas
)
logger.info(f"✅ 批量添加记忆成功: {len(memories)}")
return len(memories)
except Exception as e:
logger.error(f"❌ 批量添加记忆失败: {str(e)}")
return 0
async def search_memories(
self,
user_id: str,
project_id: str,
query: str,
memory_types: Optional[List[str]] = None,
limit: int = 10,
min_importance: float = 0.0,
chapter_range: Optional[tuple] = None
) -> List[Dict[str, Any]]:
"""
语义搜索相关记忆
Args:
user_id: 用户ID
project_id: 项目ID
query: 查询文本(会被转换为向量进行相似度搜索)
memory_types: 过滤特定类型的记忆
limit: 返回结果数量
min_importance: 最低重要性阈值
chapter_range: 章节范围 (start, end)
Returns:
相关记忆列表,按相似度排序
"""
try:
collection = self.get_collection(user_id, project_id)
# 生成查询向量
query_embedding = self.embedding_model.encode(query).tolist()
# 构建过滤条件 - ChromaDB要求使用$and组合多个条件
where_filter = None
conditions = []
if memory_types:
conditions.append({"memory_type": {"$in": memory_types}})
if min_importance > 0:
conditions.append({"importance": {"$gte": min_importance}})
if chapter_range:
conditions.append({"chapter_number": {"$gte": chapter_range[0]}})
conditions.append({"chapter_number": {"$lte": chapter_range[1]}})
# 根据条件数量选择合适的格式
if len(conditions) == 0:
where_filter = None
elif len(conditions) == 1:
where_filter = conditions[0]
else:
where_filter = {"$and": conditions}
# 执行向量相似度搜索
results = collection.query(
query_embeddings=[query_embedding],
n_results=limit,
where=where_filter
)
# 格式化结果
memories = []
if results['ids'] and results['ids'][0]:
for i in range(len(results['ids'][0])):
memories.append({
"id": results['ids'][0][i],
"content": results['documents'][0][i],
"metadata": results['metadatas'][0][i],
"similarity": 1 - results['distances'][0][i] if 'distances' in results else 1.0,
"distance": results['distances'][0][i] if 'distances' in results else 0.0
})
logger.info(f"🔍 语义搜索完成: 查询='{query[:30]}...', 找到{len(memories)}条记忆")
return memories
except Exception as e:
logger.error(f"❌ 搜索记忆失败: {str(e)}")
return []
async def get_recent_memories(
self,
user_id: str,
project_id: str,
current_chapter: int,
recent_count: int = 3,
min_importance: float = 0.5
) -> List[Dict[str, Any]]:
"""
获取最近几章的重要记忆(用于保持连贯性)
Args:
user_id: 用户ID
project_id: 项目ID
current_chapter: 当前章节号
recent_count: 获取最近几章
min_importance: 最低重要性阈值
Returns:
最近章节的记忆列表,按重要性排序
"""
try:
collection = self.get_collection(user_id, project_id)
# 计算章节范围
start_chapter = max(1, current_chapter - recent_count)
# 获取最近章节的记忆
results = collection.get(
where={
"$and": [
{"chapter_number": {"$gte": start_chapter}},
{"chapter_number": {"$lt": current_chapter}},
{"importance": {"$gte": min_importance}}
]
},
limit=100 # 先获取足够多的记忆
)
memories = []
if results['ids']:
for i in range(len(results['ids'])):
memories.append({
"id": results['ids'][i],
"content": results['documents'][i],
"metadata": results['metadatas'][i]
})
# 按重要性和章节号排序
memories.sort(
key=lambda x: (float(x['metadata'].get('importance', 0)),
int(x['metadata'].get('chapter_number', 0))),
reverse=True
)
# 返回最重要的前N条
top_memories = memories[:20]
logger.info(f"📚 获取最近记忆: 章节{start_chapter}-{current_chapter-1}, 找到{len(top_memories)}")
return top_memories
except Exception as e:
logger.error(f"❌ 获取最近记忆失败: {str(e)}")
return []
async def find_unresolved_foreshadows(
self,
user_id: str,
project_id: str,
current_chapter: int
) -> List[Dict[str, Any]]:
"""
查找未完结的伏笔
Args:
user_id: 用户ID
project_id: 项目ID
current_chapter: 当前章节号
Returns:
未完结伏笔列表
"""
try:
collection = self.get_collection(user_id, project_id)
# 查找伏笔状态为1(已埋下但未回收)的记忆
results = collection.get(
where={
"$and": [
{"is_foreshadow": 1},
{"chapter_number": {"$lt": current_chapter}}
]
},
limit=50
)
foreshadows = []
if results['ids']:
for i in range(len(results['ids'])):
foreshadows.append({
"id": results['ids'][i],
"content": results['documents'][i],
"metadata": results['metadatas'][i]
})
# 按重要性排序
foreshadows.sort(
key=lambda x: float(x['metadata'].get('importance', 0)),
reverse=True
)
logger.info(f"🎣 找到未完结伏笔: {len(foreshadows)}")
return foreshadows
except Exception as e:
logger.error(f"❌ 查找伏笔失败: {str(e)}")
return []
async def build_context_for_generation(
self,
user_id: str,
project_id: str,
current_chapter: int,
chapter_outline: str,
character_names: List[str] = None
) -> Dict[str, Any]:
"""
为章节生成构建智能上下文
这是核心功能: 结合多种检索策略,为AI生成提供最相关的记忆
Args:
user_id: 用户ID
project_id: 项目ID
current_chapter: 当前章节号
chapter_outline: 本章大纲
character_names: 涉及的角色名列表
Returns:
包含各种上下文信息的字典
"""
logger.info(f"🧠 开始构建章节{current_chapter}的智能上下文...")
# 1. 获取最近章节上下文(时间连续性)
recent = await self.get_recent_memories(
user_id, project_id, current_chapter,
recent_count=3, min_importance=0.5
)
# 2. 语义搜索相关记忆
relevant = await self.search_memories(
user_id=user_id,
project_id=project_id,
query=chapter_outline,
limit=10,
min_importance=0.4
)
# 3. 查找未完结伏笔
foreshadows = await self.find_unresolved_foreshadows(
user_id, project_id, current_chapter
)
# 4. 如果有指定角色,获取角色相关记忆
character_memories = []
if character_names:
character_query = " ".join(character_names) + " 角色 状态 关系"
character_memories = await self.search_memories(
user_id=user_id,
project_id=project_id,
query=character_query,
memory_types=["character_event", "plot_point"],
limit=8
)
# 5. 获取重要情节点
# 注意:ChromaDB的where条件需要特殊处理,不能同时使用多个顶层条件
try:
plot_points = await self.search_memories(
user_id=user_id,
project_id=project_id,
query="重要 转折 高潮 关键",
memory_types=["plot_point", "hook"],
limit=5,
min_importance=0.7
)
except Exception as e:
logger.error(f"❌ 搜索记忆失败: {str(e)}")
# 降级处理:分别查询
plot_points = []
try:
plot_points = await self.search_memories(
user_id=user_id,
project_id=project_id,
query="重要 转折 高潮 关键",
memory_types=["plot_point", "hook"],
limit=5
)
except Exception as e2:
logger.warning(f"⚠️ 降级查询也失败: {str(e2)}")
plot_points = []
context = {
"recent_context": self._format_memories(recent, "最近章节记忆"),
"relevant_memories": self._format_memories(relevant, "语义相关记忆"),
"character_states": self._format_memories(character_memories, "角色相关记忆"),
"foreshadows": self._format_memories(foreshadows[:5], "未完结伏笔"),
"plot_points": self._format_memories(plot_points, "重要情节点"),
"stats": {
"recent_count": len(recent),
"relevant_count": len(relevant),
"character_count": len(character_memories),
"foreshadow_count": len(foreshadows),
"plot_point_count": len(plot_points)
}
}
logger.info(f"✅ 上下文构建完成: 最近{len(recent)}条, 相关{len(relevant)}条, 伏笔{len(foreshadows)}")
return context
def _format_memories(self, memories: List[Dict], section_title: str = "记忆") -> str:
"""
格式化记忆列表为文本
Args:
memories: 记忆列表
section_title: 章节标题
Returns:
格式化后的文本
"""
if not memories:
return f"{section_title}\n暂无相关记忆\n"
lines = [f"{section_title}"]
for i, mem in enumerate(memories, 1):
meta = mem.get('metadata', {})
chapter_num = meta.get('chapter_number', '?')
mem_type = meta.get('memory_type', '未知')
importance = float(meta.get('importance', 0.5))
title = meta.get('title', '')
content = mem['content']
# 格式: [序号] 第X章-类型(重要性) 标题: 内容
line = f"{i}. [第{chapter_num}章-{mem_type}{importance:.1f}]"
if title:
line += f" {title}: {content[:100]}"
else:
line += f" {content[:150]}"
lines.append(line)
return "\n".join(lines) + "\n"
async def delete_chapter_memories(
self,
user_id: str,
project_id: str,
chapter_id: str
) -> bool:
"""
删除指定章节的所有记忆
Args:
user_id: 用户ID
project_id: 项目ID
chapter_id: 章节ID
Returns:
是否删除成功
"""
try:
collection = self.get_collection(user_id, project_id)
# 查找该章节的所有记忆
results = collection.get(
where={"chapter_id": chapter_id}
)
if results['ids']:
# 删除这些记忆
collection.delete(ids=results['ids'])
logger.info(f"🗑️ 已删除章节{chapter_id[:8]}{len(results['ids'])}条记忆")
return True
else:
logger.info(f"️ 章节{chapter_id[:8]}没有记忆需要删除")
return True
except Exception as e:
logger.error(f"❌ 删除章节记忆失败: {str(e)}")
return False
async def update_memory(
self,
user_id: str,
project_id: str,
memory_id: str,
content: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> bool:
"""
更新记忆内容或元数据
Args:
user_id: 用户ID
project_id: 项目ID
memory_id: 记忆ID
content: 新内容(可选)
metadata: 新元数据(可选)
Returns:
是否更新成功
"""
try:
collection = self.get_collection(user_id, project_id)
update_data = {}
if content:
# 重新生成embedding
embedding = self.embedding_model.encode(content).tolist()
update_data['embeddings'] = [embedding]
update_data['documents'] = [content]
if metadata:
# 准备新的元数据
chroma_metadata = {}
for key, value in metadata.items():
if isinstance(value, (list, dict)):
chroma_metadata[key] = json.dumps(value, ensure_ascii=False)
else:
chroma_metadata[key] = value
update_data['metadatas'] = [chroma_metadata]
if update_data:
collection.update(
ids=[memory_id],
**update_data
)
logger.info(f"✅ 记忆已更新: {memory_id[:8]}...")
return True
else:
logger.warning("⚠️ 没有提供更新内容")
return False
except Exception as e:
logger.error(f"❌ 更新记忆失败: {str(e)}")
return False
async def get_memory_stats(
self,
user_id: str,
project_id: str
) -> Dict[str, Any]:
"""
获取记忆统计信息
Args:
user_id: 用户ID
project_id: 项目ID
Returns:
统计信息字典
"""
try:
collection = self.get_collection(user_id, project_id)
# 获取所有记忆
all_memories = collection.get()
if not all_memories['ids']:
return {
"total_count": 0,
"by_type": {},
"by_chapter": {},
"foreshadow_count": 0
}
# 统计各类型数量
type_counts = {}
chapter_counts = {}
foreshadow_count = 0
for i, meta in enumerate(all_memories['metadatas']):
mem_type = meta.get('memory_type', 'unknown')
chapter_num = meta.get('chapter_number', 0)
is_foreshadow = meta.get('is_foreshadow', 0)
type_counts[mem_type] = type_counts.get(mem_type, 0) + 1
chapter_counts[str(chapter_num)] = chapter_counts.get(str(chapter_num), 0) + 1
if is_foreshadow == 1:
foreshadow_count += 1
stats = {
"total_count": len(all_memories['ids']),
"by_type": type_counts,
"by_chapter": chapter_counts,
"foreshadow_count": foreshadow_count,
"foreshadow_resolved": sum(1 for m in all_memories['metadatas'] if m.get('is_foreshadow') == 2)
}
logger.info(f"📊 记忆统计: 总计{stats['total_count']}条, 伏笔{foreshadow_count}")
return stats
except Exception as e:
logger.error(f"❌ 获取统计信息失败: {str(e)}")
return {"error": str(e)}
# 创建全局实例
memory_service = MemoryService()
+559
View File
@@ -0,0 +1,559 @@
"""剧情分析服务 - 自动分析章节的钩子、伏笔、冲突等元素"""
from typing import Dict, Any, List, Optional
from app.services.ai_service import AIService
from app.logger import get_logger
import json
import re
logger = get_logger(__name__)
class PlotAnalyzer:
"""剧情分析器 - 使用AI分析章节内容"""
# AI分析提示词模板
ANALYSIS_PROMPT = """你是一位专业的小说编辑和剧情分析师。请深度分析以下章节内容:
**章节信息:**
- 章节: 第{chapter_number}
- 标题: {title}
- 字数: {word_count}
**章节内容:**
{content}
---
**分析任务:**
请从专业编辑的角度,全面分析这一章节:
### 1. 剧情钩子 (Hooks) - 吸引读者的元素
识别能够吸引读者继续阅读的关键元素:
- **悬念钩子**: 未解之谜、疑问、谜团
- **情感钩子**: 引发共鸣的情感点、触动心弦的时刻
- **冲突钩子**: 矛盾对抗、紧张局势
- **认知钩子**: 颠覆认知的信息、惊人真相
每个钩子需要:
- 类型分类
- 具体内容描述
- 强度评分(1-10)
- 出现位置(开头/中段/结尾)
- **关键词**: 【必填】从章节原文中逐字复制一段关键文本(8-25字),必须是原文中真实存在的连续文字,用于在文本中精确定位。不要概括或改写,必须原样复制!
### 2. 伏笔分析 (Foreshadowing)
- **埋下的新伏笔**: 描述内容、预期作用、隐藏程度(1-10)
- **回收的旧伏笔**: 呼应哪一章、回收效果评分
- **伏笔质量**: 巧妙性和合理性评估
- **关键词**: 【必填】从章节原文中逐字复制一段关键文本(8-25字),必须是原文中真实存在的连续文字,用于在文本中精确定位。不要概括或改写,必须原样复制!
### 3. 冲突分析 (Conflict)
- 冲突类型: 人与人/人与己/人与环境/人与社会
- 冲突各方及其立场
- 冲突强度评分(1-10)
- 冲突解决进度(0-100%)
### 4. 情感曲线 (Emotional Arc)
- 主导情绪: 紧张/温馨/悲伤/激昂/平静等
- 情感强度(1-10)
- 情绪变化轨迹描述
### 5. 角色状态追踪 (Character Development)
对每个出场角色分析:
- 心理状态变化(前→后)
- 关系变化
- 关键行动和决策
- 成长或退步
### 6. 关键情节点 (Plot Points)
列出3-5个核心情节点:
- 情节内容
- 类型(revelation/conflict/resolution/transition)
- 重要性(0.0-1.0)
- 对故事的影响
- **关键词**: 【必填】从章节原文中逐字复制一段关键文本(8-25字),必须是原文中真实存在的连续文字,用于在文本中精确定位。不要概括或改写,必须原样复制!
### 7. 场景与节奏
- 主要场景
- 叙事节奏(快/中/慢)
- 对话与描写的比例
### 8. 质量评分
- 节奏把控: 1-10分
- 吸引力: 1-10分
- 连贯性: 1-10分
- 整体质量: 1-10分
### 9. 改进建议
提供3-5条具体的改进建议
---
**输出格式(纯JSON,不要markdown标记):**
{{
"hooks": [
{{
"type": "悬念",
"content": "具体描述",
"strength": 8,
"position": "中段",
"keyword": "必须从原文逐字复制的文本片段"
}}
],
"foreshadows": [
{{
"content": "伏笔内容",
"type": "planted",
"strength": 7,
"subtlety": 8,
"reference_chapter": null,
"keyword": "必须从原文逐字复制的文本片段"
}}
],
"conflict": {{
"types": ["人与人", "人与己"],
"parties": ["主角-复仇", "反派-维护现状"],
"level": 8,
"description": "冲突描述",
"resolution_progress": 0.3
}},
"emotional_arc": {{
"primary_emotion": "紧张",
"intensity": 8,
"curve": "平静→紧张→高潮→释放",
"secondary_emotions": ["期待", "焦虑"]
}},
"character_states": [
{{
"character_name": "张三",
"state_before": "犹豫",
"state_after": "坚定",
"psychological_change": "心理变化描述",
"key_event": "触发事件",
"relationship_changes": {{"李四": "关系改善"}}
}}
],
"plot_points": [
{{
"content": "情节点描述",
"type": "revelation",
"importance": 0.9,
"impact": "推动故事发展",
"keyword": "必须从原文逐字复制的文本片段"
}}
],
"scenes": [
{{
"location": "地点",
"atmosphere": "氛围",
"duration": "时长估计"
}}
],
"pacing": "varied",
"dialogue_ratio": 0.4,
"description_ratio": 0.3,
"scores": {{
"pacing": 8,
"engagement": 9,
"coherence": 8,
"overall": 8.5
}},
"plot_stage": "发展",
"suggestions": [
"具体建议1",
"具体建议2"
]
}}
**重要提示:**
1. 每个钩子、伏笔、情节点的keyword字段是必填的,不能为空
2. keyword必须是从章节原文中逐字复制的文本,长度8-25字
3. keyword用于在前端标注文本位置,所以必须能在原文中精确找到
4. 不要使用概括性语句或改写后的文字作为keyword
只返回JSON,不要其他说明。"""
def __init__(self, ai_service: AIService):
"""
初始化剧情分析器
Args:
ai_service: AI服务实例
"""
self.ai_service = ai_service
logger.info("✅ PlotAnalyzer初始化成功")
async def analyze_chapter(
self,
chapter_number: int,
title: str,
content: str,
word_count: int
) -> Optional[Dict[str, Any]]:
"""
分析单章内容
Args:
chapter_number: 章节号
title: 章节标题
content: 章节内容
word_count: 字数
Returns:
分析结果字典,失败返回None
"""
try:
logger.info(f"🔍 开始分析第{chapter_number}章: {title}")
# 如果内容过长,截取前8000字(避免超token)
analysis_content = content[:8000] if len(content) > 8000 else content
# 构建提示词
prompt = self.ANALYSIS_PROMPT.format(
chapter_number=chapter_number,
title=title,
word_count=word_count,
content=analysis_content
)
# 调用AI进行分析
# 注意:不指定max_tokens,使用用户在设置中配置的值
logger.info(f" 调用AI分析(内容长度: {len(analysis_content)}字)...")
response = await self.ai_service.generate_text(
prompt=prompt,
temperature=0.3 # 降低温度以获得更稳定的JSON输出
)
# 解析JSON结果
analysis_result = self._parse_analysis_response(response)
if analysis_result:
logger.info(f"✅ 第{chapter_number}章分析完成")
logger.info(f" - 钩子: {len(analysis_result.get('hooks', []))}")
logger.info(f" - 伏笔: {len(analysis_result.get('foreshadows', []))}")
logger.info(f" - 情节点: {len(analysis_result.get('plot_points', []))}")
logger.info(f" - 整体评分: {analysis_result.get('scores', {}).get('overall', 'N/A')}")
return analysis_result
else:
logger.error(f"❌ 第{chapter_number}章分析失败: JSON解析错误")
return None
except Exception as e:
logger.error(f"❌ 章节分析异常: {str(e)}")
return None
def _parse_analysis_response(self, response: str) -> Optional[Dict[str, Any]]:
"""
解析AI返回的分析结果
Args:
response: AI返回的文本
Returns:
解析后的字典,失败返回None
"""
try:
# 清理响应文本
cleaned = response.strip()
# 移除可能的markdown标记
cleaned = re.sub(r'^```json\s*', '', cleaned)
cleaned = re.sub(r'^```\s*', '', cleaned)
cleaned = re.sub(r'\s*```$', '', cleaned)
# 尝试解析JSON
result = json.loads(cleaned)
# 验证必要字段
required_fields = ['hooks', 'plot_points', 'scores']
for field in required_fields:
if field not in result:
logger.warning(f"⚠️ 分析结果缺少字段: {field}")
result[field] = [] if field != 'scores' else {}
return result
except json.JSONDecodeError as e:
logger.error(f"❌ JSON解析失败: {str(e)}")
logger.error(f" 原始响应(前500字): {response[:500]}")
# 尝试提取JSON部分
json_match = re.search(r'\{[\s\S]*\}', response)
if json_match:
try:
result = json.loads(json_match.group())
logger.info("✅ 通过正则提取成功解析JSON")
return result
except:
pass
return None
except Exception as e:
logger.error(f"❌ 解析异常: {str(e)}")
return None
def extract_memories_from_analysis(
self,
analysis: Dict[str, Any],
chapter_id: str,
chapter_number: int,
chapter_content: str = ""
) -> List[Dict[str, Any]]:
"""
从分析结果中提取记忆片段
Args:
analysis: 分析结果
chapter_id: 章节ID
chapter_number: 章节号
chapter_content: 章节完整内容(用于计算位置)
Returns:
记忆片段列表
"""
memories = []
try:
# 1. 提取钩子作为记忆
for i, hook in enumerate(analysis.get('hooks', [])):
if hook.get('strength', 0) >= 6: # 只保存强度>=6的钩子
keyword = hook.get('keyword', '')
position, length = self._find_text_position(chapter_content, keyword)
logger.info(f" 钩子位置: keyword='{keyword[:30]}...', pos={position}, len={length}")
memories.append({
'type': 'hook',
'content': f"[{hook.get('type', '未知')}钩子] {hook.get('content', '')}",
'title': f"{hook.get('type', '钩子')} - {hook.get('position', '')}",
'metadata': {
'chapter_id': chapter_id,
'chapter_number': chapter_number,
'importance_score': min(hook.get('strength', 5) / 10, 1.0),
'tags': [hook.get('type', '钩子'), hook.get('position', '')],
'is_foreshadow': 0,
'keyword': keyword,
'text_position': position,
'text_length': length,
'strength': hook.get('strength', 5),
'position_desc': hook.get('position', '')
}
})
# 2. 提取伏笔作为记忆
for i, foreshadow in enumerate(analysis.get('foreshadows', [])):
is_planted = foreshadow.get('type') == 'planted'
keyword = foreshadow.get('keyword', '')
position, length = self._find_text_position(chapter_content, keyword)
logger.info(f" 伏笔位置: keyword='{keyword[:30]}...', pos={position}, len={length}")
memories.append({
'type': 'foreshadow',
'content': foreshadow.get('content', ''),
'title': f"{'埋下伏笔' if is_planted else '回收伏笔'}",
'metadata': {
'chapter_id': chapter_id,
'chapter_number': chapter_number,
'importance_score': min(foreshadow.get('strength', 5) / 10, 1.0),
'tags': ['伏笔', foreshadow.get('type', 'planted')],
'is_foreshadow': 1 if is_planted else 2,
'reference_chapter': foreshadow.get('reference_chapter'),
'keyword': keyword,
'text_position': position,
'text_length': length,
'foreshadow_type': foreshadow.get('type', 'planted'),
'strength': foreshadow.get('strength', 5)
}
})
# 3. 提取关键情节点
for i, plot_point in enumerate(analysis.get('plot_points', [])):
if plot_point.get('importance', 0) >= 0.6: # 只保存重要性>=0.6的情节点
keyword = plot_point.get('keyword', '')
position, length = self._find_text_position(chapter_content, keyword)
logger.info(f" 情节点位置: keyword='{keyword[:30]}...', pos={position}, len={length}")
memories.append({
'type': 'plot_point',
'content': f"{plot_point.get('content', '')}。影响: {plot_point.get('impact', '')}",
'title': f"情节点 - {plot_point.get('type', '未知')}",
'metadata': {
'chapter_id': chapter_id,
'chapter_number': chapter_number,
'importance_score': plot_point.get('importance', 0.5),
'tags': ['情节点', plot_point.get('type', '未知')],
'is_foreshadow': 0,
'keyword': keyword,
'text_position': position,
'text_length': length
}
})
# 4. 提取角色状态变化
for i, char_state in enumerate(analysis.get('character_states', [])):
char_name = char_state.get('character_name', '未知角色')
memories.append({
'type': 'character_event',
'content': f"{char_name}的状态变化: {char_state.get('state_before', '')}{char_state.get('state_after', '')}{char_state.get('psychological_change', '')}",
'title': f"{char_name}的变化",
'metadata': {
'chapter_id': chapter_id,
'chapter_number': chapter_number,
'importance_score': 0.7,
'tags': ['角色', char_name, '状态变化'],
'related_characters': [char_name],
'is_foreshadow': 0
}
})
# 5. 如果有重要冲突,也记录下来
conflict = analysis.get('conflict', {})
if conflict and conflict.get('level', 0) >= 7:
# 确保 parties 和 types 都是字符串列表
parties = conflict.get('parties', [])
if parties and isinstance(parties, list):
parties = [str(p) for p in parties]
types = conflict.get('types', [])
if types and isinstance(types, list):
types = [str(t) for t in types]
memories.append({
'type': 'plot_point',
'content': f"重要冲突: {conflict.get('description', '')}。冲突各方: {', '.join(parties)}",
'title': f"冲突 - 强度{conflict.get('level', 0)}",
'metadata': {
'chapter_id': chapter_id,
'chapter_number': chapter_number,
'importance_score': min(conflict.get('level', 5) / 10, 1.0),
'tags': ['冲突'] + types,
'is_foreshadow': 0
}
})
logger.info(f"📝 从分析中提取了{len(memories)}条记忆")
return memories
except Exception as e:
logger.error(f"❌ 提取记忆失败: {str(e)}")
return []
def _find_text_position(self, full_text: str, keyword: str) -> tuple[int, int]:
"""
在全文中查找关键词位置
Args:
full_text: 完整文本
keyword: 关键词
Returns:
(起始位置, 长度) 如果未找到返回(-1, 0)
"""
if not keyword or not full_text:
return (-1, 0)
try:
# 1. 精确匹配
pos = full_text.find(keyword)
if pos != -1:
return (pos, len(keyword))
# 2. 去除标点符号后匹配
import re
clean_keyword = re.sub(r'[,。!?、;:""''()《》【】]', '', keyword)
clean_text = re.sub(r'[,。!?、;:""''()《》【】]', '', full_text)
pos = clean_text.find(clean_keyword)
if pos != -1:
# 反向映射到原文位置(简化处理)
return (pos, len(clean_keyword))
# 3. 模糊匹配:查找关键词的前半部分
if len(keyword) > 10:
partial = keyword[:min(15, len(keyword))]
pos = full_text.find(partial)
if pos != -1:
return (pos, len(partial))
# 4. 未找到
logger.debug(f"未找到关键词位置: {keyword[:30]}...")
return (-1, 0)
except Exception as e:
logger.error(f"查找位置失败: {str(e)}")
return (-1, 0)
def generate_analysis_summary(self, analysis: Dict[str, Any]) -> str:
"""
生成分析摘要文本
Args:
analysis: 分析结果
Returns:
格式化的摘要文本
"""
try:
lines = ["=== 章节分析报告 ===\n"]
# 整体评分
scores = analysis.get('scores', {})
lines.append(f"【整体评分】")
lines.append(f" 整体质量: {scores.get('overall', 'N/A')}/10")
lines.append(f" 节奏把控: {scores.get('pacing', 'N/A')}/10")
lines.append(f" 吸引力: {scores.get('engagement', 'N/A')}/10")
lines.append(f" 连贯性: {scores.get('coherence', 'N/A')}/10\n")
# 剧情阶段
lines.append(f"【剧情阶段】{analysis.get('plot_stage', '未知')}\n")
# 钩子统计
hooks = analysis.get('hooks', [])
if hooks:
lines.append(f"【钩子分析】共{len(hooks)}")
for hook in hooks[:3]: # 只显示前3个
lines.append(f" • [{hook.get('type')}] {hook.get('content', '')[:50]}... (强度:{hook.get('strength', 0)})")
lines.append("")
# 伏笔统计
foreshadows = analysis.get('foreshadows', [])
if foreshadows:
planted = sum(1 for f in foreshadows if f.get('type') == 'planted')
resolved = sum(1 for f in foreshadows if f.get('type') == 'resolved')
lines.append(f"【伏笔分析】埋下{planted}个, 回收{resolved}\n")
# 冲突分析
conflict = analysis.get('conflict', {})
if conflict:
lines.append(f"【冲突分析】")
lines.append(f" 类型: {', '.join(conflict.get('types', []))}")
lines.append(f" 强度: {conflict.get('level', 0)}/10")
lines.append(f" 进度: {int(conflict.get('resolution_progress', 0) * 100)}%\n")
# 改进建议
suggestions = analysis.get('suggestions', [])
if suggestions:
lines.append(f"【改进建议】")
for i, sug in enumerate(suggestions, 1):
lines.append(f" {i}. {sug}")
return "\n".join(lines)
except Exception as e:
logger.error(f"❌ 生成摘要失败: {str(e)}")
return "分析摘要生成失败"
# 创建全局实例(需要时手动初始化)
_plot_analyzer_instance = None
def get_plot_analyzer(ai_service: AIService) -> PlotAnalyzer:
"""获取剧情分析器实例"""
global _plot_analyzer_instance
if _plot_analyzer_instance is None:
_plot_analyzer_instance = PlotAnalyzer(ai_service)
return _plot_analyzer_instance
+81 -15
View File
@@ -315,7 +315,7 @@ class PromptService:
2. 数组中要包含{chapter_count}个章节对象
3. 文本中不要使用中文引号(""),改用【】或《》"""
# 大纲续写提示词
# 大纲续写提示词(记忆增强版)
OUTLINE_CONTINUE_GENERATION = """你是一位经验丰富的小说作家和编剧。请基于以下信息续写小说大纲:
【项目信息】
@@ -340,6 +340,11 @@ class PromptService:
【最近剧情】
{recent_plot}
【🧠 智能记忆系统 - 续写参考】
以下是从故事记忆库中检索到的相关信息,请在续写大纲时参考:
{memory_context}
【续写指导】
- 当前情节阶段:{plot_stage_instruction}
- 起始章节编号:第{start_chapter}
@@ -348,10 +353,12 @@ class PromptService:
请生成第{start_chapter}章到第{end_chapter}章的大纲。
要求:
- 与前文自然衔接,保持故事连贯性
- 遵循情节阶段的发展要求
- 保持与已有章节相同的风格和详细程度
- 推进角色成长和情节发展
- **剧情连贯性**与前文自然衔接,保持故事连贯性
- **记忆参考**:适当参考记忆系统中的伏笔、钩子和情节点
- **伏笔回收**:可以考虑回收未完结的伏笔,制造呼应
- **角色发展**:遵循角色在前文中的成长轨迹
- **情节阶段**:遵循情节阶段的发展要求
- **风格一致**:保持与已有章节相同的风格和详细程度
**重要格式要求:**
1. 只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字
@@ -465,7 +472,7 @@ class PromptService:
请直接输出章节正文内容,不要包含章节标题和其他说明文字。"""
# 章节完整创作提示词(带前置章节上下文)
# 章节完整创作提示词(带前置章节上下文和记忆增强
CHAPTER_GENERATION_WITH_CONTEXT = """你是一位专业的小说作家。请根据以下信息创作本章内容:
项目信息:
@@ -489,6 +496,11 @@ class PromptService:
【已完成的前置章节内容】
{previous_content}
【🧠 智能记忆系统 - 重要参考】
以下是从故事记忆库中检索到的相关信息,请在创作时适当参考和呼应:
{memory_context}
本章信息:
- 章节序号:第{chapter_number}
- 章节标题:{chapter_title}
@@ -518,8 +530,15 @@ class PromptService:
- 体现世界观特色
5. **承上启下**
- 开头自然衔接上一章结尾
- 结尾为下一章做好铺垫
- 开头自然衔接上一章结尾
- 结尾为下一章做好铺垫
6. **记忆系统使用指南**
- **最近章节记忆**:保持情节连贯,注意角色状态和剧情发展
- **语义相关记忆**:参考相似情节的处理方式
- **未完结伏笔**:适当时机可以回收伏笔,制造呼应效果
- **角色状态记忆**:确保角色行为符合其发展轨迹
- **重要情节点**:与关键剧情保持一致
请直接输出章节正文内容,不要包含章节标题和其他说明文字。"""
@@ -746,14 +765,26 @@ class PromptService:
characters_info: str, outlines_context: str,
chapter_number: int, chapter_title: str,
chapter_outline: str, style_content: str = "",
target_word_count: int = 3000) -> str:
target_word_count: int = 3000,
memory_context: dict = None) -> str:
"""
获取章节完整创作提示词
Args:
style_content: 写作风格要求内容,如果提供则会追加到提示词中
target_word_count: 目标字数,默认3000字
memory_context: 记忆上下文(可选)
"""
# 格式化记忆上下文
memory_text = ""
if memory_context:
memory_text = "\n【🧠 智能记忆系统 - 重要参考】\n"
memory_text += memory_context.get('recent_context', '')
memory_text += "\n" + memory_context.get('relevant_memories', '')
memory_text += "\n" + memory_context.get('foreshadows', '')
memory_text += "\n" + memory_context.get('character_states', '')
memory_text += "\n" + memory_context.get('plot_points', '')
base_prompt = cls.format_prompt(
cls.CHAPTER_GENERATION,
title=title,
@@ -772,6 +803,13 @@ class PromptService:
target_word_count=target_word_count
)
# 插入记忆上下文
if memory_text:
base_prompt = base_prompt.replace(
"本章信息:",
memory_text + "\n\n本章信息:"
)
# 如果有风格要求,应用到提示词中
if style_content:
return WritingStyleManager.apply_style_to_prompt(base_prompt, style_content)
@@ -786,14 +824,27 @@ class PromptService:
previous_content: str, chapter_number: int,
chapter_title: str, chapter_outline: str,
style_content: str = "",
target_word_count: int = 3000) -> str:
target_word_count: int = 3000,
memory_context: dict = None) -> str:
"""
获取章节完整创作提示词(带前置章节上下文)
获取章节完整创作提示词(带前置章节上下文和记忆增强
Args:
style_content: 写作风格要求内容,如果提供则会追加到提示词中
target_word_count: 目标字数,默认3000字
memory_context: 记忆上下文(可选)
"""
# 格式化记忆上下文
memory_text = ""
if memory_context:
memory_text = memory_context.get('recent_context', '')
memory_text += "\n" + memory_context.get('relevant_memories', '')
memory_text += "\n" + memory_context.get('foreshadows', '')
memory_text += "\n" + memory_context.get('character_states', '')
memory_text += "\n" + memory_context.get('plot_points', '')
else:
memory_text = "暂无相关记忆"
base_prompt = cls.format_prompt(
cls.CHAPTER_GENERATION_WITH_CONTEXT,
title=title,
@@ -810,7 +861,8 @@ class PromptService:
chapter_number=chapter_number,
chapter_title=chapter_title,
chapter_outline=chapter_outline,
target_word_count=target_word_count
target_word_count=target_word_count,
memory_context=memory_text
)
# 如果有风格要求,应用到提示词中
@@ -839,9 +891,22 @@ class PromptService:
current_chapter_count: int, all_chapters_brief: str,
recent_plot: str, plot_stage_instruction: str,
start_chapter: int, story_direction: str,
requirements: str = "") -> str:
"""获取大纲续写提示词"""
requirements: str = "",
memory_context: dict = None) -> str:
"""获取大纲续写提示词(支持记忆增强)"""
end_chapter = start_chapter + chapter_count - 1
# 格式化记忆上下文
memory_text = ""
if memory_context:
memory_text = memory_context.get('recent_context', '')
memory_text += "\n" + memory_context.get('relevant_memories', '')
memory_text += "\n" + memory_context.get('foreshadows', '')
memory_text += "\n" + memory_context.get('character_states', '')
memory_text += "\n" + memory_context.get('plot_points', '')
else:
memory_text = "暂无相关记忆(可能是首次续写或记忆库为空)"
return cls.format_prompt(
cls.OUTLINE_CONTINUE_GENERATION,
title=title,
@@ -861,7 +926,8 @@ class PromptService:
start_chapter=start_chapter,
end_chapter=end_chapter,
story_direction=story_direction,
requirements=requirements or "无特殊要求"
requirements=requirements or "无特殊要求",
memory_context=memory_text
)
@classmethod
@@ -0,0 +1 @@
86741b4e3f5cb7765a600d3a3d55a0f6a6cb443d
@@ -0,0 +1,7 @@
{
"word_embedding_dimension": 384,
"pooling_mode_cls_token": false,
"pooling_mode_mean_tokens": true,
"pooling_mode_max_tokens": false,
"pooling_mode_mean_sqrt_len_tokens": false
}
@@ -0,0 +1,156 @@
---
language:
- multilingual
- ar
- bg
- ca
- cs
- da
- de
- el
- en
- es
- et
- fa
- fi
- fr
- gl
- gu
- he
- hi
- hr
- hu
- hy
- id
- it
- ja
- ka
- ko
- ku
- lt
- lv
- mk
- mn
- mr
- ms
- my
- nb
- nl
- pl
- pt
- ro
- ru
- sk
- sl
- sq
- sr
- sv
- th
- tr
- uk
- ur
- vi
license: apache-2.0
library_name: sentence-transformers
tags:
- sentence-transformers
- feature-extraction
- sentence-similarity
- transformers
language_bcp47:
- fr-ca
- pt-br
- zh-cn
- zh-tw
pipeline_tag: sentence-similarity
---
# sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
This is a [sentence-transformers](https://www.SBERT.net) model: It maps sentences & paragraphs to a 384 dimensional dense vector space and can be used for tasks like clustering or semantic search.
## Usage (Sentence-Transformers)
Using this model becomes easy when you have [sentence-transformers](https://www.SBERT.net) installed:
```
pip install -U sentence-transformers
```
Then you can use the model like this:
```python
from sentence_transformers import SentenceTransformer
sentences = ["This is an example sentence", "Each sentence is converted"]
model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
embeddings = model.encode(sentences)
print(embeddings)
```
## Usage (HuggingFace Transformers)
Without [sentence-transformers](https://www.SBERT.net), you can use the model like this: First, you pass your input through the transformer model, then you have to apply the right pooling-operation on-top of the contextualized word embeddings.
```python
from transformers import AutoTokenizer, AutoModel
import torch
# Mean Pooling - Take attention mask into account for correct averaging
def mean_pooling(model_output, attention_mask):
token_embeddings = model_output[0] #First element of model_output contains all token embeddings
input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
return torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp(input_mask_expanded.sum(1), min=1e-9)
# Sentences we want sentence embeddings for
sentences = ['This is an example sentence', 'Each sentence is converted']
# Load model from HuggingFace Hub
tokenizer = AutoTokenizer.from_pretrained('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
model = AutoModel.from_pretrained('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
# Tokenize sentences
encoded_input = tokenizer(sentences, padding=True, truncation=True, return_tensors='pt')
# Compute token embeddings
with torch.no_grad():
model_output = model(**encoded_input)
# Perform pooling. In this case, max pooling.
sentence_embeddings = mean_pooling(model_output, encoded_input['attention_mask'])
print("Sentence embeddings:")
print(sentence_embeddings)
```
## Full Model Architecture
```
SentenceTransformer(
(0): Transformer({'max_seq_length': 128, 'do_lower_case': False}) with Transformer model: BertModel
(1): Pooling({'word_embedding_dimension': 384, 'pooling_mode_cls_token': False, 'pooling_mode_mean_tokens': True, 'pooling_mode_max_tokens': False, 'pooling_mode_mean_sqrt_len_tokens': False})
)
```
## Citing & Authors
This model was trained by [sentence-transformers](https://www.sbert.net/).
If you find this model helpful, feel free to cite our publication [Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks](https://arxiv.org/abs/1908.10084):
```bibtex
@inproceedings{reimers-2019-sentence-bert,
title = "Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks",
author = "Reimers, Nils and Gurevych, Iryna",
booktitle = "Proceedings of the 2019 Conference on Empirical Methods in Natural Language Processing",
month = "11",
year = "2019",
publisher = "Association for Computational Linguistics",
url = "http://arxiv.org/abs/1908.10084",
}
```
@@ -0,0 +1,24 @@
{
"_name_or_path": "old_models/paraphrase-multilingual-MiniLM-L12-v2/0_Transformer",
"architectures": [
"BertModel"
],
"attention_probs_dropout_prob": 0.1,
"gradient_checkpointing": false,
"hidden_act": "gelu",
"hidden_dropout_prob": 0.1,
"hidden_size": 384,
"initializer_range": 0.02,
"intermediate_size": 1536,
"layer_norm_eps": 1e-12,
"max_position_embeddings": 512,
"model_type": "bert",
"num_attention_heads": 12,
"num_hidden_layers": 12,
"pad_token_id": 0,
"position_embedding_type": "absolute",
"transformers_version": "4.7.0",
"type_vocab_size": 2,
"use_cache": true,
"vocab_size": 250037
}
@@ -0,0 +1,7 @@
{
"__version__": {
"sentence_transformers": "2.0.0",
"transformers": "4.7.0",
"pytorch": "1.9.0+cu102"
}
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:eaa086f0ffee582aeb45b36e34cdd1fe2d6de2bef61f8a559a1bbc9bd955917b
size 470641600
@@ -0,0 +1,14 @@
[
{
"idx": 0,
"name": "0",
"path": "",
"type": "sentence_transformers.models.Transformer"
},
{
"idx": 1,
"name": "1",
"path": "1_Pooling",
"type": "sentence_transformers.models.Pooling"
}
]
@@ -0,0 +1 @@
{"bos_token": "<s>", "eos_token": "</s>", "unk_token": "<unk>", "sep_token": "</s>", "pad_token": "<pad>", "cls_token": "<s>", "mask_token": {"content": "<mask>", "single_word": false, "lstrip": true, "rstrip": false, "normalized": false}}
@@ -0,0 +1 @@
{"do_lower_case": true, "unk_token": "<unk>", "sep_token": "</s>", "pad_token": "<pad>", "cls_token": "<s>", "mask_token": {"content": "<mask>", "single_word": false, "lstrip": true, "rstrip": false, "normalized": true, "__type": "AddedToken"}, "tokenize_chinese_chars": true, "strip_accents": null, "bos_token": "<s>", "eos_token": "</s>", "model_max_length": 512, "special_tokens_map_file": null, "name_or_path": "old_models/paraphrase-multilingual-MiniLM-L12-v2/0_Transformer"}
+17 -4
View File
@@ -12,9 +12,22 @@ pydantic==2.5.3
pydantic-settings==2.1.0
# AI服务
openai==1.10.0
anthropic==0.18.0
openai==2.7.0
anthropic==0.72.0
# 工具库
httpx==0.26.0
python-dotenv==1.0.0
httpx==0.28.1
python-dotenv==1.0.0
# NumPy版本锁定(兼容性要求)
numpy==1.26.4
# 向量数据库和Embedding (长期记忆系统)
chromadb==1.3.2
# Transformers(锁定兼容版本)
transformers==4.35.2
# Sentence Transformers(基于PyTorch的文本embedding库)
sentence-transformers==2.3.1
+4
View File
@@ -10,6 +10,8 @@ import Characters from './pages/Characters';
import Relationships from './pages/Relationships';
import Organizations from './pages/Organizations';
import Chapters from './pages/Chapters';
import ChapterReader from './pages/ChapterReader';
import ChapterAnalysis from './pages/ChapterAnalysis';
import WritingStyles from './pages/WritingStyles';
import Settings from './pages/Settings';
// import Polish from './pages/Polish';
@@ -34,6 +36,7 @@ function App() {
<Route path="/" element={<ProtectedRoute><ProjectList /></ProtectedRoute>} />
<Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
<Route path="/chapters/:chapterId/reader" element={<ProtectedRoute><ChapterReader /></ProtectedRoute>} />
<Route path="/project/:projectId" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>}>
<Route index element={<Navigate to="world-setting" replace />} />
<Route path="world-setting" element={<WorldSetting />} />
@@ -42,6 +45,7 @@ function App() {
<Route path="relationships" element={<Relationships />} />
<Route path="organizations" element={<Organizations />} />
<Route path="chapters" element={<Chapters />} />
<Route path="chapter-analysis" element={<ChapterAnalysis />} />
<Route path="writing-styles" element={<WritingStyles />} />
{/* <Route path="polish" element={<Polish />} /> */}
</Route>
+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;
+537
View File
@@ -0,0 +1,537 @@
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();
}
// 清理函数:组件卸载或关闭时清除轮询
return () => {
// 清除可能存在的轮询
};
}, [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 || '触发分析失败');
}
// 触发成功后立即关闭Modal,让父组件的状态管理接管
onClose();
} 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 && !loading && (
<Button
key="analyze"
type="primary"
icon={<ReloadOutlined />}
onClick={triggerAnalysis}
loading={loading}
>
</Button>
),
task && (task.status === 'failed') && (
<Button
key="reanalyze"
type="primary"
icon={<ReloadOutlined />}
onClick={triggerAnalysis}
loading={loading}
danger
>
</Button>
),
task && task.status === 'completed' && (
<Button
key="reanalyze"
type="default"
icon={<ReloadOutlined />}
onClick={triggerAnalysis}
loading={loading}
>
</Button>
)
].filter(Boolean)}
>
{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
/>
)}
{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;
+374
View File
@@ -0,0 +1,374 @@
import React, { useState, useEffect } from 'react';
import { Card, List, Button, Space, Empty, Tag, Spin, Alert, Switch, Drawer, message } from 'antd';
import {
EyeOutlined,
EyeInvisibleOutlined,
MenuOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons';
import { useParams } from 'react-router-dom';
import api from '../services/api';
import AnnotatedText, { type MemoryAnnotation } from '../components/AnnotatedText';
import MemorySidebar from '../components/MemorySidebar';
interface ChapterItem {
id: string;
chapter_number: number;
title: string;
content: string;
word_count: number;
status: string;
}
interface AnnotationsData {
chapter_id: string;
chapter_number: number;
title: string;
word_count: number;
annotations: MemoryAnnotation[];
has_analysis: boolean;
summary: {
total_annotations: number;
hooks: number;
foreshadows: number;
plot_points: number;
character_events: number;
};
}
interface NavigationData {
current: {
id: string;
chapter_number: number;
title: string;
};
previous: {
id: string;
chapter_number: number;
title: string;
} | null;
next: {
id: string;
chapter_number: number;
title: string;
} | null;
}
/**
* 项目内的章节剧情分析页面
* 显示章节列表和带标注的章节内容
*/
const ChapterAnalysis: React.FC = () => {
const { projectId } = useParams<{ projectId: string }>();
const [chapters, setChapters] = useState<ChapterItem[]>([]);
const [selectedChapter, setSelectedChapter] = useState<ChapterItem | null>(null);
const [annotationsData, setAnnotationsData] = useState<AnnotationsData | null>(null);
const [navigation, setNavigation] = useState<NavigationData | null>(null);
const [loading, setLoading] = useState(true);
const [contentLoading, setContentLoading] = useState(false);
const [showAnnotations, setShowAnnotations] = useState(true);
const [activeAnnotationId, setActiveAnnotationId] = useState<string | undefined>();
const [sidebarVisible, setSidebarVisible] = useState(false);
const [scrollToContentAnnotation, setScrollToContentAnnotation] = useState<string | undefined>();
const [scrollToSidebarAnnotation, setScrollToSidebarAnnotation] = useState<string | undefined>();
// 加载章节列表
useEffect(() => {
const loadChapters = async () => {
if (!projectId) return;
try {
setLoading(true);
const response = await api.get(`/chapters/project/${projectId}`);
// API 拦截器已经解析了 response.data,所以直接使用
const data = response.data || response;
const chapterList = data.items || [];
setChapters(chapterList);
// 自动选择第一个有内容的章节
const firstChapterWithContent = chapterList.find((ch: ChapterItem) => ch.content && ch.content.trim() !== '');
if (firstChapterWithContent) {
loadChapterContent(firstChapterWithContent.id);
}
} catch (error) {
console.error('加载章节列表失败:', error);
message.error('加载章节列表失败');
} finally {
setLoading(false);
}
};
loadChapters();
}, [projectId]);
// 加载章节内容和标注
const loadChapterContent = async (chapterId: string) => {
try {
setContentLoading(true);
const [chapterResponse, annotationsResponse, navigationResponse] = await Promise.all([
api.get(`/chapters/${chapterId}`),
api.get(`/chapters/${chapterId}/annotations`).catch(() => null),
api.get(`/chapters/${chapterId}/navigation`).catch(() => null),
]);
// 提取 data 属性
setSelectedChapter(chapterResponse.data || chapterResponse);
setAnnotationsData(annotationsResponse ? (annotationsResponse.data || annotationsResponse) : null);
setNavigation(navigationResponse ? (navigationResponse.data || navigationResponse) : null);
} catch (error) {
console.error('加载章节内容失败:', error);
message.error('加载章节内容失败');
} finally {
setContentLoading(false);
}
};
const handleChapterSelect = (chapterId: string) => {
loadChapterContent(chapterId);
};
const handlePreviousChapter = () => {
if (navigation?.previous) {
loadChapterContent(navigation.previous.id);
}
};
const handleNextChapter = () => {
if (navigation?.next) {
loadChapterContent(navigation.next.id);
}
};
const handleAnnotationClick = (annotation: MemoryAnnotation, source: 'content' | 'sidebar' = 'content') => {
setActiveAnnotationId(annotation.id);
if (source === 'content') {
// 从内容区点击,滚动到侧边栏
setScrollToSidebarAnnotation(annotation.id);
// 清除滚动状态
setTimeout(() => setScrollToSidebarAnnotation(undefined), 100);
if (window.innerWidth < 768) {
setSidebarVisible(true);
}
} else {
// 从侧边栏点击,滚动到内容区
setScrollToContentAnnotation(annotation.id);
// 清除滚动状态
setTimeout(() => setScrollToContentAnnotation(undefined), 100);
}
};
const hasAnnotations = annotationsData && annotationsData.annotations.length > 0;
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Spin size="large" tip="加载章节中..." />
</div>
);
}
return (
<div style={{ display: 'flex', height: '100%', gap: 16 }}>
{/* 左侧章节列表 */}
<Card
title="章节列表"
style={{ width: 280, height: '100%', overflow: 'hidden' }}
bodyStyle={{ padding: 0, height: 'calc(100% - 57px)', overflow: 'auto' }}
>
{chapters.length === 0 ? (
<Empty description="暂无章节" style={{ marginTop: 60 }} />
) : (
<List
dataSource={chapters}
renderItem={(chapter) => (
<List.Item
key={chapter.id}
onClick={() => handleChapterSelect(chapter.id)}
style={{
cursor: 'pointer',
padding: '12px 16px',
background: selectedChapter?.id === chapter.id ? '#e6f7ff' : 'transparent',
borderLeft: selectedChapter?.id === chapter.id ? '3px solid #1890ff' : '3px solid transparent',
}}
>
<List.Item.Meta
title={
<span style={{ fontSize: 14, fontWeight: selectedChapter?.id === chapter.id ? 600 : 400 }}>
{chapter.chapter_number}: {chapter.title}
</span>
}
description={
<Space size={4}>
<Tag color={chapter.content && chapter.content.trim() !== '' ? 'success' : 'default'}>
{chapter.word_count || 0}
</Tag>
</Space>
}
/>
</List.Item>
)}
/>
)}
</Card>
{/* 右侧内容区域 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{!selectedChapter ? (
<Card style={{ height: '100%' }}>
<Empty description="请从左侧选择一个章节查看" style={{ marginTop: 100 }} />
</Card>
) : (
<>
{/* 工具栏 */}
<Card size="small" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Button
icon={<LeftOutlined />}
onClick={handlePreviousChapter}
disabled={!navigation?.previous}
title={navigation?.previous ? `上一章: ${navigation.previous.title}` : '已是第一章'}
>
</Button>
<span style={{ fontSize: 16, fontWeight: 600 }}>
{selectedChapter.chapter_number}: {selectedChapter.title}
</span>
<Button
icon={<RightOutlined />}
onClick={handleNextChapter}
disabled={!navigation?.next}
title={navigation?.next ? `下一章: ${navigation.next.title}` : '已是最后一章'}
>
</Button>
</Space>
<Space>
{hasAnnotations && (
<>
<Switch
checked={showAnnotations}
onChange={setShowAnnotations}
checkedChildren={<EyeOutlined />}
unCheckedChildren={<EyeInvisibleOutlined />}
/>
<span style={{ fontSize: 13, color: '#666' }}></span>
<Button
icon={<MenuOutlined />}
onClick={() => setSidebarVisible(true)}
style={{ display: window.innerWidth < 768 ? 'inline-block' : 'none' }}
>
</Button>
</>
)}
</Space>
</div>
{hasAnnotations && annotationsData && (
<div style={{ marginTop: 12, fontSize: 12, color: '#999' }}>
{annotationsData.summary.total_annotations}
{annotationsData.summary.hooks > 0 && ` 🎣${annotationsData.summary.hooks}个钩子`}
{annotationsData.summary.foreshadows > 0 &&
` 🌟${annotationsData.summary.foreshadows}个伏笔`}
{annotationsData.summary.plot_points > 0 &&
` 💎${annotationsData.summary.plot_points}个情节点`}
{annotationsData.summary.character_events > 0 &&
` 👤${annotationsData.summary.character_events}个角色事件`}
</div>
)}
</Card>
{/* 内容区域 */}
<div style={{ flex: 1, display: 'flex', gap: 16, overflow: 'hidden' }}>
{/* 章节内容 */}
<Card
style={{ flex: 1, overflow: 'auto' }}
loading={contentLoading}
>
{!contentLoading && (
<>
{!hasAnnotations && (
<Alert
message="暂无分析数据"
description="该章节尚未进行AI分析,无法显示记忆标注。"
type="info"
showIcon
style={{ marginBottom: 24 }}
/>
)}
{showAnnotations && hasAnnotations && annotationsData ? (
<AnnotatedText
content={selectedChapter.content}
annotations={annotationsData.annotations}
onAnnotationClick={(annotation) => handleAnnotationClick(annotation, 'content')}
activeAnnotationId={activeAnnotationId}
scrollToAnnotation={scrollToContentAnnotation}
/>
) : (
<div
style={{
lineHeight: 2,
fontSize: 16,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{selectedChapter.content}
</div>
)}
</>
)}
</Card>
{/* 右侧记忆侧边栏(桌面端) */}
{hasAnnotations && annotationsData && window.innerWidth >= 768 && (
<Card
style={{ width: 400, overflow: 'auto' }}
bodyStyle={{ padding: 0 }}
>
<MemorySidebar
annotations={annotationsData.annotations}
activeAnnotationId={activeAnnotationId}
onAnnotationClick={(annotation) => handleAnnotationClick(annotation, 'sidebar')}
scrollToAnnotation={scrollToSidebarAnnotation}
/>
</Card>
)}
</div>
{/* 移动端抽屉 */}
{hasAnnotations && annotationsData && (
<Drawer
title="章节分析"
placement="right"
onClose={() => setSidebarVisible(false)}
open={sidebarVisible}
width="80%"
>
<MemorySidebar
annotations={annotationsData.annotations}
activeAnnotationId={activeAnnotationId}
onAnnotationClick={(annotation) => {
handleAnnotationClick(annotation, 'sidebar');
setSidebarVisible(false);
}}
scrollToAnnotation={scrollToSidebarAnnotation}
/>
</Drawer>
)}
</>
)}
</div>
</div>
);
};
export default ChapterAnalysis;
+456
View File
@@ -0,0 +1,456 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Spin, Alert, Button, Space, Switch, Drawer, message, Progress } from 'antd';
import {
ArrowLeftOutlined,
EyeOutlined,
EyeInvisibleOutlined,
MenuOutlined,
ReloadOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons';
import api from '../services/api';
import AnnotatedText, { type MemoryAnnotation } from '../components/AnnotatedText';
import MemorySidebar from '../components/MemorySidebar';
interface ChapterData {
id: string;
chapter_number: number;
title: string;
content: string;
word_count: number;
}
interface AnnotationsData {
chapter_id: string;
chapter_number: number;
title: string;
word_count: number;
annotations: MemoryAnnotation[];
has_analysis: boolean;
summary: {
total_annotations: number;
hooks: number;
foreshadows: number;
plot_points: number;
character_events: number;
};
}
interface NavigationData {
current: {
id: string;
chapter_number: number;
title: string;
};
previous: {
id: string;
chapter_number: number;
title: string;
} | null;
next: {
id: string;
chapter_number: number;
title: string;
} | null;
}
/**
* 章节阅读器页面
* 展示带有记忆标注的章节内容
*/
const ChapterReader: React.FC = () => {
const { chapterId } = useParams<{ chapterId: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [chapter, setChapter] = useState<ChapterData | null>(null);
const [annotationsData, setAnnotationsData] = useState<AnnotationsData | null>(null);
const [showAnnotations, setShowAnnotations] = useState(true);
const [activeAnnotationId, setActiveAnnotationId] = useState<string | undefined>();
const [sidebarVisible, setSidebarVisible] = useState(false);
const [analyzing, setAnalyzing] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
const [navigation, setNavigation] = useState<NavigationData | null>(null);
useEffect(() => {
if (chapterId) {
loadChapterData();
}
}, [chapterId]);
const loadChapterData = async () => {
try {
setLoading(true);
setError(null);
// 并行加载章节内容、标注数据和导航信息
// 注意:api拦截器已经解析了response.data,所以直接返回数据对象
const [chapterData, annotationsData, navigationData] = await Promise.all([
api.get<unknown, ChapterData>(`/chapters/${chapterId}`).catch(err => {
console.error('加载章节失败:', err);
throw err;
}),
api.get<unknown, AnnotationsData>(`/chapters/${chapterId}/annotations`).catch(err => {
console.warn('加载标注失败:', err);
return null;
}), // 如果没有分析数据也不报错
api.get<unknown, NavigationData>(`/chapters/${chapterId}/navigation`).catch(err => {
console.warn('加载导航信息失败:', err);
return null;
}),
]);
console.log('章节数据:', chapterData);
console.log('标注数据:', annotationsData);
console.log('导航数据:', navigationData);
// 验证数据
if (!chapterData || !chapterData.content) {
throw new Error('章节数据无效:缺少内容');
}
setChapter(chapterData);
setNavigation(navigationData);
// 验证标注数据
if (annotationsData) {
const validAnnotations = annotationsData.annotations.filter(
(a: MemoryAnnotation) => a.position >= 0 && a.position < chapterData.content.length
);
const invalidCount = annotationsData.annotations.length - validAnnotations.length;
if (invalidCount > 0) {
console.warn(`${invalidCount}个标注位置无效,将仅显示${validAnnotations.length}个有效标注`);
}
setAnnotationsData(annotationsData);
} else {
setAnnotationsData(null);
}
} catch (err: any) {
console.error('加载章节数据失败:', err);
setError(err.response?.data?.detail || err.message || '加载失败');
} finally {
setLoading(false);
}
};
const handleAnnotationClick = (annotation: MemoryAnnotation) => {
setActiveAnnotationId(annotation.id);
// 移动端显示侧边栏
if (window.innerWidth < 768) {
setSidebarVisible(true);
}
};
const handleBackClick = () => {
navigate(-1);
};
const handlePreviousChapter = () => {
if (navigation?.previous) {
navigate(`/chapters/${navigation.previous.id}/reader`);
}
};
const handleNextChapter = () => {
if (navigation?.next) {
navigate(`/chapters/${navigation.next.id}/reader`);
}
};
const handleReanalyze = async () => {
if (!chapterId) return;
try {
setAnalyzing(true);
setAnalysisProgress(0);
message.loading({ content: '开始分析章节...', key: 'analyze', duration: 0 });
// 触发分析
await api.post(`/chapters/${chapterId}/analyze`);
// 轮询分析状态
const pollInterval = setInterval(async () => {
try {
const statusRes = await api.get(`/chapters/${chapterId}/analysis/status`);
const { status, progress, error_message } = statusRes.data;
setAnalysisProgress(progress || 0);
if (status === 'completed') {
clearInterval(pollInterval);
setAnalyzing(false);
message.success({ content: '分析完成!', key: 'analyze' });
// 重新加载标注数据
const annotationsRes = await api.get(`/chapters/${chapterId}/annotations`);
setAnnotationsData(annotationsRes.data);
} else if (status === 'failed') {
clearInterval(pollInterval);
setAnalyzing(false);
message.error({
content: `分析失败:${error_message || '未知错误'}`,
key: 'analyze'
});
}
} catch (err) {
console.error('轮询分析状态失败:', err);
}
}, 2000); // 每2秒轮询一次
// 30秒超时
setTimeout(() => {
clearInterval(pollInterval);
if (analyzing) {
setAnalyzing(false);
message.warning({ content: '分析超时,请稍后刷新查看结果', key: 'analyze' });
}
}, 30000);
} catch (err: any) {
setAnalyzing(false);
message.error({
content: err.response?.data?.detail || '触发分析失败',
key: 'analyze'
});
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Spin size="large" tip="加载章节中..." />
</div>
);
}
if (error || !chapter) {
return (
<div style={{ padding: 24 }}>
<Alert
message="加载失败"
description={error || '章节不存在'}
type="error"
showIcon
/>
<Button onClick={handleBackClick} style={{ marginTop: 16 }}>
</Button>
</div>
);
}
const hasAnnotations = annotationsData && annotationsData.annotations.length > 0;
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* 顶部工具栏 */}
<Card
size="small"
style={{
borderRadius: 0,
borderLeft: 0,
borderRight: 0,
borderTop: 0,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={handleBackClick}>
</Button>
<Button
icon={<LeftOutlined />}
onClick={handlePreviousChapter}
disabled={!navigation?.previous}
title={navigation?.previous ? `上一章: ${navigation.previous.title}` : '已是第一章'}
>
</Button>
<span style={{ fontSize: 16, fontWeight: 600 }}>
{chapter.chapter_number}: {chapter.title}
</span>
<Button
icon={<RightOutlined />}
onClick={handleNextChapter}
disabled={!navigation?.next}
title={navigation?.next ? `下一章: ${navigation.next.title}` : '已是最后一章'}
>
</Button>
</Space>
<Space>
<Button
icon={<ReloadOutlined />}
onClick={handleReanalyze}
loading={analyzing}
disabled={analyzing}
>
{analyzing ? '分析中...' : '重新分析'}
</Button>
{hasAnnotations && (
<>
<Switch
checked={showAnnotations}
onChange={setShowAnnotations}
checkedChildren={<EyeOutlined />}
unCheckedChildren={<EyeInvisibleOutlined />}
/>
<span style={{ fontSize: 13, color: '#666' }}></span>
<Button
icon={<MenuOutlined />}
onClick={() => setSidebarVisible(true)}
style={{ display: window.innerWidth < 768 ? 'inline-block' : 'none' }}
>
</Button>
</>
)}
</Space>
</div>
{analyzing && (
<div style={{ marginTop: 12 }}>
<Progress percent={analysisProgress} size="small" status="active" />
<span style={{ fontSize: 12, color: '#666', marginLeft: 8 }}>
...
</span>
</div>
)}
{!analyzing && hasAnnotations && annotationsData && (
<div style={{ marginTop: 12, fontSize: 12, color: '#999' }}>
{annotationsData.summary.total_annotations}
{annotationsData.summary.hooks > 0 && ` 🎣${annotationsData.summary.hooks}个钩子`}
{annotationsData.summary.foreshadows > 0 &&
` 🌟${annotationsData.summary.foreshadows}个伏笔`}
{annotationsData.summary.plot_points > 0 &&
` 💎${annotationsData.summary.plot_points}个情节点`}
{annotationsData.summary.character_events > 0 &&
` 👤${annotationsData.summary.character_events}个角色事件`}
</div>
)}
</Card>
{/* 主内容区域 */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* 左侧:章节内容 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '32px 48px',
maxWidth: hasAnnotations ? 'calc(100% - 400px)' : '100%',
}}
>
<Card>
<div style={{ maxWidth: 800, margin: '0 auto' }}>
{!hasAnnotations && (
<Alert
message="暂无分析数据"
description="该章节尚未进行AI分析,无法显示记忆标注。"
type="info"
showIcon
style={{ marginBottom: 24 }}
/>
)}
{showAnnotations && hasAnnotations && annotationsData ? (
<AnnotatedText
content={chapter.content}
annotations={annotationsData.annotations}
onAnnotationClick={handleAnnotationClick}
activeAnnotationId={activeAnnotationId}
/>
) : (
<div
style={{
lineHeight: 2,
fontSize: 16,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{chapter.content}
</div>
)}
{/* 底部翻页按钮 */}
<div style={{ marginTop: 48, paddingTop: 24, borderTop: '1px solid #f0f0f0' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button
size="large"
icon={<LeftOutlined />}
onClick={handlePreviousChapter}
disabled={!navigation?.previous}
>
{navigation?.previous
? `上一章: 第${navigation.previous.chapter_number}${navigation.previous.title}`
: '已是第一章'}
</Button>
<Button
size="large"
type="primary"
icon={<RightOutlined />}
onClick={handleNextChapter}
disabled={!navigation?.next}
iconPosition="end"
>
{navigation?.next
? `下一章: 第${navigation.next.chapter_number}${navigation.next.title}`
: '已是最后一章'}
</Button>
</Space>
</div>
</div>
</Card>
</div>
{/* 右侧:记忆侧边栏(桌面端) */}
{hasAnnotations && annotationsData && window.innerWidth >= 768 && (
<div
style={{
width: 400,
borderLeft: '1px solid #f0f0f0',
overflowY: 'auto',
background: '#fafafa',
}}
>
<MemorySidebar
annotations={annotationsData.annotations}
activeAnnotationId={activeAnnotationId}
onAnnotationClick={handleAnnotationClick}
/>
</div>
)}
</div>
{/* 移动端抽屉 */}
{hasAnnotations && annotationsData && (
<Drawer
title="章节分析"
placement="right"
onClose={() => setSidebarVisible(false)}
open={sidebarVisible}
width="80%"
>
<MemorySidebar
annotations={annotationsData.annotations}
activeAnnotationId={activeAnnotationId}
onAnnotationClick={(annotation) => {
handleAnnotationClick(annotation);
setSidebarVisible(false);
}}
/>
</Drawer>
)}
</div>
);
};
export default ChapterReader;
+266 -6
View File
@@ -1,11 +1,12 @@
import { useState, useEffect, useRef } from 'react';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber } from 'antd';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useChapterSync } from '../store/hooks';
import { projectApi, writingStyleApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle } from '../types';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask } from '../types';
import { cardStyles } from '../components/CardStyles';
import ChapterAnalysis from '../components/ChapterAnalysis';
const { TextArea } = Input;
@@ -23,6 +24,11 @@ export default function Chapters() {
const [writingStyles, setWritingStyles] = useState<WritingStyle[]>([]);
const [selectedStyleId, setSelectedStyleId] = useState<number | undefined>();
const [targetWordCount, setTargetWordCount] = useState<number>(3000);
const [analysisVisible, setAnalysisVisible] = useState(false);
const [analysisChapterId, setAnalysisChapterId] = useState<string | null>(null);
// 分析任务状态管理
const [analysisTasksMap, setAnalysisTasksMap] = useState<Record<string, AnalysisTask>>({});
const pollingIntervalsRef = useRef<Record<string, number>>({});
useEffect(() => {
const handleResize = () => {
@@ -43,10 +49,96 @@ export default function Chapters() {
if (currentProject?.id) {
refreshChapters();
loadWritingStyles();
loadAnalysisTasks();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProject?.id]);
// 清理轮询定时器
useEffect(() => {
return () => {
Object.values(pollingIntervalsRef.current).forEach(interval => {
clearInterval(interval);
});
};
}, []);
// 加载所有章节的分析任务状态
const loadAnalysisTasks = async () => {
if (!chapters || chapters.length === 0) return;
const tasksMap: Record<string, AnalysisTask> = {};
for (const chapter of chapters) {
// 只查询有内容的章节
if (chapter.content && chapter.content.trim() !== '') {
try {
const response = await fetch(`/api/chapters/${chapter.id}/analysis/status`);
if (response.ok) {
const task: AnalysisTask = await response.json();
tasksMap[chapter.id] = task;
// 如果任务正在运行,启动轮询
if (task.status === 'pending' || task.status === 'running') {
startPollingTask(chapter.id);
}
}
} catch (error) {
// 404或其他错误表示没有分析任务,忽略
console.debug(`章节 ${chapter.id} 暂无分析任务`);
}
}
}
setAnalysisTasksMap(tasksMap);
};
// 启动单个章节的任务轮询
const startPollingTask = (chapterId: string) => {
// 如果已经在轮询,先清除
if (pollingIntervalsRef.current[chapterId]) {
clearInterval(pollingIntervalsRef.current[chapterId]);
}
const interval = window.setInterval(async () => {
try {
const response = await fetch(`/api/chapters/${chapterId}/analysis/status`);
if (!response.ok) return;
const task: AnalysisTask = await response.json();
setAnalysisTasksMap(prev => ({
...prev,
[chapterId]: task
}));
// 任务完成或失败,停止轮询
if (task.status === 'completed' || task.status === 'failed') {
clearInterval(pollingIntervalsRef.current[chapterId]);
delete pollingIntervalsRef.current[chapterId];
if (task.status === 'completed') {
message.success(`章节分析完成`);
} else if (task.status === 'failed') {
message.error(`章节分析失败: ${task.error_message || '未知错误'}`);
}
}
} catch (error) {
console.error('轮询分析任务失败:', error);
}
}, 2000);
pollingIntervalsRef.current[chapterId] = interval;
// 5分钟超时
setTimeout(() => {
if (pollingIntervalsRef.current[chapterId]) {
clearInterval(pollingIntervalsRef.current[chapterId]);
delete pollingIntervalsRef.current[chapterId];
}
}, 300000);
};
const loadWritingStyles = async () => {
if (!currentProject?.id) return;
@@ -159,7 +251,7 @@ export default function Chapters() {
setIsContinuing(true);
setIsGenerating(true);
await generateChapterContentStream(editingId, (content) => {
const result = await generateChapterContentStream(editingId, (content) => {
editorForm.setFieldsValue({ content });
if (contentTextAreaRef.current) {
@@ -170,7 +262,24 @@ export default function Chapters() {
}
}, selectedStyleId, targetWordCount);
message.success('AI创作成功');
message.success('AI创作成功,正在分析章节内容...');
// 如果返回了分析任务ID,启动轮询
if (result?.analysis_task_id) {
const taskId = result.analysis_task_id;
setAnalysisTasksMap(prev => ({
...prev,
[editingId]: {
task_id: taskId,
chapter_id: editingId,
status: 'pending',
progress: 0
}
}));
// 启动轮询
startPollingTask(editingId);
}
} catch (error) {
const apiError = error as ApiError;
message.error('AI创作失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误'));
@@ -322,6 +431,51 @@ export default function Chapters() {
});
};
const handleShowAnalysis = (chapterId: string) => {
setAnalysisChapterId(chapterId);
setAnalysisVisible(true);
};
// 渲染分析状态标签
const renderAnalysisStatus = (chapterId: string) => {
const task = analysisTasksMap[chapterId];
if (!task) {
return null;
}
switch (task.status) {
case 'pending':
return (
<Tag icon={<SyncOutlined spin />} color="processing">
</Tag>
);
case 'running':
return (
<Tag icon={<SyncOutlined spin />} color="processing">
{task.progress}%
</Tag>
);
case 'completed':
return (
<Tag icon={<CheckCircleOutlined />} color="success">
</Tag>
);
case 'failed':
return (
<Tooltip title={task.error_message}>
<Tag icon={<CloseCircleOutlined />} color="error">
</Tag>
</Tooltip>
);
default:
return null;
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
@@ -372,15 +526,38 @@ export default function Chapters() {
}}
actions={isMobile ? undefined : [
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
>
</Button>,
(() => {
const task = analysisTasksMap[item.id];
const isAnalyzing = task && (task.status === 'pending' || task.status === 'running');
const hasContent = item.content && item.content.trim() !== '';
return (
<Tooltip
title={
!hasContent ? '请先生成章节内容' :
isAnalyzing ? '分析进行中,请稍候...' :
''
}
>
<Button
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
>
{isAnalyzing ? '分析中' : '查看分析'}
</Button>
</Tooltip>
);
})(),
<Button
type="text"
icon={<EditOutlined />}
icon={<SettingOutlined />}
onClick={() => handleOpenModal(item.id)}
>
@@ -395,6 +572,7 @@ export default function Chapters() {
<span>{item.chapter_number}{item.title}</span>
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
<Badge count={`${item.word_count || 0}`} style={{ backgroundColor: '#52c41a' }} />
{renderAnalysisStatus(item.id)}
{!canGenerateChapter(item) && (
<Tooltip title={getGenerateDisabledReason(item)}>
<Tag icon={<LockOutlined />} color="warning">
@@ -425,6 +603,30 @@ export default function Chapters() {
size="small"
title="编辑内容"
/>
{(() => {
const task = analysisTasksMap[item.id];
const isAnalyzing = task && (task.status === 'pending' || task.status === 'running');
const hasContent = item.content && item.content.trim() !== '';
return (
<Tooltip
title={
!hasContent ? '请先生成章节内容' :
isAnalyzing ? '分析中' :
'查看分析'
}
>
<Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
size="small"
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
/>
</Tooltip>
);
})()}
<Button
type="text"
icon={<SettingOutlined />}
@@ -654,6 +856,64 @@ export default function Chapters() {
</Form.Item>
</Form>
</Modal>
{analysisChapterId && (
<ChapterAnalysis
chapterId={analysisChapterId}
visible={analysisVisible}
onClose={() => {
setAnalysisVisible(false);
// 延迟500ms后刷新该章节的分析状态,给后端足够时间完成数据库写入
if (analysisChapterId) {
const chapterIdToRefresh = analysisChapterId;
setTimeout(() => {
fetch(`/api/chapters/${chapterIdToRefresh}/analysis/status`)
.then(response => {
if (response.ok) {
return response.json();
}
throw new Error('获取状态失败');
})
.then((task: AnalysisTask) => {
setAnalysisTasksMap(prev => ({
...prev,
[chapterIdToRefresh]: task
}));
// 如果任务正在运行,启动轮询
if (task.status === 'pending' || task.status === 'running') {
startPollingTask(chapterIdToRefresh);
}
})
.catch(error => {
console.error('刷新分析状态失败:', error);
// 如果查询失败,再延迟尝试一次
setTimeout(() => {
fetch(`/api/chapters/${chapterIdToRefresh}/analysis/status`)
.then(response => response.ok ? response.json() : null)
.then((task: AnalysisTask | null) => {
if (task) {
setAnalysisTasksMap(prev => ({
...prev,
[chapterIdToRefresh]: task
}));
if (task.status === 'pending' || task.status === 'running') {
startPollingTask(chapterIdToRefresh);
}
}
})
.catch(err => console.error('第二次刷新失败:', err));
}, 1000);
});
}, 500);
}
setAnalysisChapterId(null);
}}
/>
)}
</div>
);
}
+11 -2
View File
@@ -13,6 +13,7 @@ import {
ApartmentOutlined,
BankOutlined,
EditOutlined,
FundOutlined,
} from '@ant-design/icons';
import { useStore } from '../store';
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
@@ -122,6 +123,11 @@ export default function ProjectDetail() {
icon: <BookOutlined />,
label: <Link to={`/project/${projectId}/chapters`}></Link>,
},
{
key: 'chapter-analysis',
icon: <FundOutlined />,
label: <Link to={`/project/${projectId}/chapter-analysis`}></Link>,
},
{
key: 'writing-styles',
icon: <EditOutlined />,
@@ -142,6 +148,7 @@ export default function ProjectDetail() {
if (path.includes('/organizations')) return 'organizations';
if (path.includes('/outline')) return 'outline';
if (path.includes('/characters')) return 'characters';
if (path.includes('/chapter-analysis')) return 'chapter-analysis';
if (path.includes('/chapters')) return 'chapters';
if (path.includes('/writing-styles')) return 'writing-styles';
// if (path.includes('/polish')) return 'polish';
@@ -259,7 +266,8 @@ export default function ProjectDetail() {
)}
{!mobile && (
<Row gutter={12} style={{ width: '450px', justifyContent: 'flex-end', zIndex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', zIndex: 1 }}>
<Row gutter={12} style={{ width: '450px', justifyContent: 'flex-end' }}>
<Col>
<Card
size="small"
@@ -344,7 +352,8 @@ export default function ProjectDetail() {
/>
</Card>
</Col>
</Row>
</Row>
</div>
)}
</Header>
+607 -42
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge, Alert } from 'antd';
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined } from '@ant-design/icons';
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge, Alert, Upload, Checkbox, Divider, Switch } from 'antd';
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import { projectApi } from '../services/api';
import { useStore } from '../store';
import { useProjectSync } from '../store/hooks';
import type { ReactNode } from 'react';
@@ -14,6 +15,18 @@ export default function ProjectList() {
const navigate = useNavigate();
const { projects, loading } = useStore();
const [showApiTip, setShowApiTip] = useState(true);
const [importModalVisible, setImportModalVisible] = useState(false);
const [exportModalVisible, setExportModalVisible] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [validationResult, setValidationResult] = useState<any>(null);
const [importing, setImporting] = useState(false);
const [validating, setValidating] = useState(false);
const [exporting, setExporting] = useState(false);
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [exportOptions, setExportOptions] = useState({
includeWritingStyles: true,
includeGenerationHistory: true,
});
const { refreshProjects, deleteProject } = useProjectSync();
@@ -122,6 +135,160 @@ export default function ProjectList() {
const totalWords = projects.reduce((sum, p) => sum + (p.current_words || 0), 0);
const activeProjects = projects.filter(p => p.status === 'writing').length;
// 处理文件选择
const handleFileSelect = async (file: File) => {
setSelectedFile(file);
setValidationResult(null);
// 验证文件
try {
setValidating(true);
const result = await projectApi.validateImportFile(file);
setValidationResult(result);
if (!result.valid) {
message.error('文件验证失败');
}
} catch (error) {
console.error('验证失败:', error);
message.error('文件验证失败');
} finally {
setValidating(false);
}
return false; // 阻止自动上传
};
// 处理导入
const handleImport = async () => {
if (!selectedFile || !validationResult?.valid) {
message.warning('请选择有效的导入文件');
return;
}
try {
setImporting(true);
const result = await projectApi.importProject(selectedFile);
if (result.success) {
message.success(`项目导入成功!${result.message}`);
setImportModalVisible(false);
setSelectedFile(null);
setValidationResult(null);
// 刷新项目列表
await refreshProjects();
// 跳转到新项目
if (result.project_id) {
navigate(`/project/${result.project_id}`);
}
} else {
message.error(result.message || '导入失败');
}
} catch (error) {
console.error('导入失败:', error);
message.error('导入失败,请重试');
} finally {
setImporting(false);
}
};
// 关闭导入对话框
const handleCloseImportModal = () => {
setImportModalVisible(false);
setSelectedFile(null);
setValidationResult(null);
};
// 打开导出对话框
const handleOpenExportModal = () => {
setExportModalVisible(true);
setSelectedProjectIds([]);
};
// 获取可导出的项目(过滤掉向导未完成的项目)
const exportableProjects = projects.filter(p => p.wizard_status === 'completed');
// 关闭导出对话框
const handleCloseExportModal = () => {
setExportModalVisible(false);
setSelectedProjectIds([]);
};
// 切换项目选择
const handleToggleProject = (projectId: string) => {
setSelectedProjectIds(prev =>
prev.includes(projectId)
? prev.filter(id => id !== projectId)
: [...prev, projectId]
);
};
// 全选/取消全选
const handleToggleAll = () => {
if (selectedProjectIds.length === exportableProjects.length) {
setSelectedProjectIds([]);
} else {
setSelectedProjectIds(exportableProjects.map(p => p.id));
}
};
// 执行导出
const handleExport = async () => {
if (selectedProjectIds.length === 0) {
message.warning('请至少选择一个项目');
return;
}
try {
setExporting(true);
if (selectedProjectIds.length === 1) {
// 单个项目导出
const projectId = selectedProjectIds[0];
const project = projects.find(p => p.id === projectId);
await projectApi.exportProjectData(projectId, {
include_generation_history: exportOptions.includeGenerationHistory,
include_writing_styles: exportOptions.includeWritingStyles
});
message.success(`项目 "${project?.title}" 导出成功`);
} else {
// 批量导出
let successCount = 0;
let failCount = 0;
for (const projectId of selectedProjectIds) {
try {
await projectApi.exportProjectData(projectId, {
include_generation_history: exportOptions.includeGenerationHistory,
include_writing_styles: exportOptions.includeWritingStyles
});
successCount++;
// 添加延迟避免浏览器阻止多个下载
await new Promise(resolve => setTimeout(resolve, 500));
} catch (error) {
console.error(`导出项目 ${projectId} 失败:`, error);
failCount++;
}
}
if (failCount === 0) {
message.success(`成功导出 ${successCount} 个项目`);
} else {
message.warning(`导出完成:成功 ${successCount} 个,失败 ${failCount}`);
}
}
handleCloseExportModal();
} catch (error) {
console.error('导出失败:', error);
message.error('导出失败,请重试');
} finally {
setExporting(false);
}
};
return (
<div style={{
minHeight: '100vh',
@@ -153,46 +320,165 @@ export default function ProjectList() {
</Text>
</Space>
</Col>
<Col xs={24} sm={12} md={14} style={{ display: 'flex', justifyContent: window.innerWidth <= 768 ? 'space-between' : 'flex-end', alignItems: 'center', gap: 16 }}>
<Button
type="primary"
size={window.innerWidth <= 768 ? 'middle' : 'large'}
icon={<RocketOutlined />}
onClick={() => navigate('/wizard')}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.4)'
}}
>
</Button>
<Button
type="default"
size={window.innerWidth <= 768 ? 'middle' : 'large'}
icon={<SettingOutlined />}
onClick={() => navigate('/settings')}
style={{
borderRadius: 8,
borderColor: '#d9d9d9',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
transition: 'all 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#667eea';
e.currentTarget.style.color = '#667eea';
e.currentTarget.style.boxShadow = '0 2px 12px rgba(102, 126, 234, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
e.currentTarget.style.color = 'rgba(0, 0, 0, 0.88)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.08)';
}}
>
API设置
</Button>
<UserMenu />
<Col xs={24} sm={12} md={14}>
{window.innerWidth <= 768 ? (
// 移动端:按钮分两行显示
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space size={8} style={{ width: '100%' }}>
<Button
type="primary"
size="middle"
icon={<RocketOutlined />}
onClick={() => navigate('/wizard')}
style={{
flex: 1,
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.4)'
}}
>
</Button>
<Button
type="default"
size="middle"
icon={<SettingOutlined />}
onClick={() => navigate('/settings')}
style={{
flex: 1,
borderRadius: 8,
borderColor: '#d9d9d9',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)'
}}
>
API设置
</Button>
<UserMenu />
</Space>
<Space size={8} style={{ width: '100%' }}>
<Button
type="default"
size="middle"
icon={<DownloadOutlined />}
onClick={handleOpenExportModal}
disabled={exportableProjects.length === 0}
style={{
flex: 1,
borderRadius: 8,
borderColor: '#1890ff',
color: '#1890ff',
boxShadow: '0 2px 8px rgba(24, 144, 255, 0.2)'
}}
>
</Button>
<Button
type="default"
size="middle"
icon={<UploadOutlined />}
onClick={() => setImportModalVisible(true)}
style={{
flex: 1,
borderRadius: 8,
borderColor: '#52c41a',
color: '#52c41a',
boxShadow: '0 2px 8px rgba(82, 196, 26, 0.2)'
}}
>
</Button>
</Space>
</Space>
) : (
// PC端:原有布局
<Space size={12} style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="primary"
size="large"
icon={<RocketOutlined />}
onClick={() => navigate('/wizard')}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.4)'
}}
>
</Button>
<Button
type="default"
size="large"
icon={<DownloadOutlined />}
onClick={handleOpenExportModal}
disabled={exportableProjects.length === 0}
style={{
borderRadius: 8,
borderColor: '#1890ff',
color: '#1890ff',
boxShadow: '0 2px 8px rgba(24, 144, 255, 0.2)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#1890ff';
e.currentTarget.style.background = '#e6f7ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#1890ff';
e.currentTarget.style.background = 'transparent';
}}
>
</Button>
<Button
type="default"
size="large"
icon={<UploadOutlined />}
onClick={() => setImportModalVisible(true)}
style={{
borderRadius: 8,
borderColor: '#52c41a',
color: '#52c41a',
boxShadow: '0 2px 8px rgba(82, 196, 26, 0.2)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#52c41a';
e.currentTarget.style.background = '#f6ffed';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#52c41a';
e.currentTarget.style.background = 'transparent';
}}
>
</Button>
<Button
type="default"
size="large"
icon={<SettingOutlined />}
onClick={() => navigate('/settings')}
style={{
borderRadius: 8,
borderColor: '#d9d9d9',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
transition: 'all 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#667eea';
e.currentTarget.style.color = '#667eea';
e.currentTarget.style.boxShadow = '0 2px 12px rgba(102, 126, 234, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
e.currentTarget.style.color = 'rgba(0, 0, 0, 0.88)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.08)';
}}
>
API设置
</Button>
<UserMenu />
</Space>
)}
</Col>
</Row>
@@ -472,6 +758,285 @@ export default function ProjectList() {
)}
</Spin>
</div>
{/* 导入项目对话框 */}
<Modal
title="导入项目"
open={importModalVisible}
onOk={handleImport}
onCancel={handleCloseImportModal}
confirmLoading={importing}
okText="导入"
cancelText="取消"
width={window.innerWidth <= 768 ? '90%' : 500}
centered
okButtonProps={{ disabled: !validationResult?.valid }}
styles={{
body: {
maxHeight: window.innerWidth <= 768 ? '60vh' : 'auto',
overflowY: 'auto',
padding: window.innerWidth <= 768 ? '16px' : '24px'
}
}}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<p style={{ marginBottom: '12px', color: '#666', fontSize: window.innerWidth <= 768 ? 13 : 14 }}>
JSON
</p>
<Upload
accept=".json"
beforeUpload={handleFileSelect}
maxCount={1}
onRemove={() => {
setSelectedFile(null);
setValidationResult(null);
}}
fileList={selectedFile ? [{ uid: '-1', name: selectedFile.name, status: 'done' }] as any : []}
>
<Button icon={<UploadOutlined />} block></Button>
</Upload>
</div>
{validating && (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin tip="验证文件中..." />
</div>
)}
{validationResult && (
<Card size="small" style={{ background: validationResult.valid ? '#f6ffed' : '#fff2f0' }}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<div>
<Text strong style={{
color: validationResult.valid ? '#52c41a' : '#ff4d4f',
fontSize: window.innerWidth <= 768 ? 13 : 14
}}>
{validationResult.valid ? '✓ 文件验证通过' : '✗ 文件验证失败'}
</Text>
</div>
{validationResult.project_name && (
<div>
<Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}></Text>
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}>{validationResult.project_name}</Text>
</div>
)}
{validationResult.statistics && Object.keys(validationResult.statistics).length > 0 && (
<div>
<Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}></Text>
<div style={{ marginTop: 8 }}>
<Row gutter={[8, 8]}>
{validationResult.statistics.chapters > 0 && (
<Col span={12}>
<Tag color="blue">: {validationResult.statistics.chapters}</Tag>
</Col>
)}
{validationResult.statistics.characters > 0 && (
<Col span={12}>
<Tag color="green">: {validationResult.statistics.characters}</Tag>
</Col>
)}
{validationResult.statistics.outlines > 0 && (
<Col span={12}>
<Tag color="purple">: {validationResult.statistics.outlines}</Tag>
</Col>
)}
{validationResult.statistics.relationships > 0 && (
<Col span={12}>
<Tag color="orange">: {validationResult.statistics.relationships}</Tag>
</Col>
)}
</Row>
</div>
</div>
)}
{validationResult.errors && validationResult.errors.length > 0 && (
<div>
<Text type="danger" strong style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}></Text>
<ul style={{
margin: '4px 0 0 0',
paddingLeft: '20px',
color: '#ff4d4f',
fontSize: window.innerWidth <= 768 ? 12 : 13
}}>
{validationResult.errors.map((error: string, index: number) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
{validationResult.warnings && validationResult.warnings.length > 0 && (
<div>
<Text type="warning" strong style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}></Text>
<ul style={{
margin: '4px 0 0 0',
paddingLeft: '20px',
color: '#faad14',
fontSize: window.innerWidth <= 768 ? 12 : 13
}}>
{validationResult.warnings.map((warning: string, index: number) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
</Space>
</Card>
)}
</Space>
</Modal>
{/* 导出项目对话框 */}
<Modal
title="导出项目"
open={exportModalVisible}
onOk={handleExport}
onCancel={handleCloseExportModal}
confirmLoading={exporting}
okText={selectedProjectIds.length > 0 ? `导出 (${selectedProjectIds.length})` : '导出'}
cancelText="取消"
width={window.innerWidth <= 768 ? '90%' : 700}
centered
okButtonProps={{ disabled: selectedProjectIds.length === 0 }}
styles={{
body: {
maxHeight: window.innerWidth <= 768 ? '70vh' : 'auto',
overflowY: 'auto',
padding: window.innerWidth <= 768 ? '16px' : '24px'
}
}}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{/* 导出选项 */}
<Card
size="small"
style={{ background: '#f5f5f5' }}
styles={{ body: { padding: window.innerWidth <= 768 ? 12 : 16 } }}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}></Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Switch
size={window.innerWidth <= 768 ? 'small' : 'default'}
checked={exportOptions.includeWritingStyles}
onChange={(checked) => setExportOptions(prev => ({ ...prev, includeWritingStyles: checked }))}
style={{
flexShrink: 0,
height: window.innerWidth <= 768 ? 16 : 22,
minHeight: window.innerWidth <= 768 ? 16 : 22,
lineHeight: window.innerWidth <= 768 ? '16px' : '22px'
}}
/>
<Text style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}></Text>
<Tooltip title="导出项目关联的写作风格数据">
<InfoCircleOutlined style={{ color: '#999', fontSize: window.innerWidth <= 768 ? 12 : 14 }} />
</Tooltip>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Switch
size={window.innerWidth <= 768 ? 'small' : 'default'}
checked={exportOptions.includeGenerationHistory}
onChange={(checked) => setExportOptions(prev => ({ ...prev, includeGenerationHistory: checked }))}
style={{
flexShrink: 0,
height: window.innerWidth <= 768 ? 16 : 22,
minHeight: window.innerWidth <= 768 ? 16 : 22,
lineHeight: window.innerWidth <= 768 ? '16px' : '22px'
}}
/>
<Text style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}></Text>
<Tooltip title="导出AI生成的历史记录(最多100条)">
<InfoCircleOutlined style={{ color: '#999', fontSize: window.innerWidth <= 768 ? 12 : 14 }} />
</Tooltip>
</div>
</Space>
</Card>
<Divider style={{ margin: '8px 0' }} />
{/* 项目列表 */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, flexWrap: window.innerWidth <= 768 ? 'wrap' : 'nowrap', gap: 8 }}>
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}>
{exportableProjects.length > 0 && <Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}>({exportableProjects.length})</Text>}
</Text>
<Checkbox
checked={selectedProjectIds.length === exportableProjects.length && exportableProjects.length > 0}
indeterminate={selectedProjectIds.length > 0 && selectedProjectIds.length < exportableProjects.length}
onChange={handleToggleAll}
style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}
>
</Checkbox>
</div>
<div style={{ maxHeight: window.innerWidth <= 768 ? 300 : 400, overflowY: 'auto' }}>
{exportableProjects.length === 0 ? (
<Empty
description="暂无可导出的项目"
style={{ padding: '40px 0' }}
/>
) : (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{exportableProjects.map((project) => (
<Card
key={project.id}
size="small"
hoverable
style={{
cursor: 'pointer',
border: selectedProjectIds.includes(project.id) ? '2px solid #1890ff' : '1px solid #d9d9d9',
background: selectedProjectIds.includes(project.id) ? '#e6f7ff' : '#fff'
}}
onClick={() => handleToggleProject(project.id)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Checkbox
checked={selectedProjectIds.includes(project.id)}
onChange={() => handleToggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
<BookOutlined style={{ fontSize: 20, color: '#1890ff' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4, flexWrap: 'wrap' }}>
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}>{project.title}</Text>
{project.genre && (
<Tag color="blue" style={{ margin: 0, fontSize: window.innerWidth <= 768 ? 11 : 12 }}>{project.genre}</Tag>
)}
{getStatusTag(project.status)}
</div>
<Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 11 : 12 }}>
{project.current_words || 0}
{project.description && ` · ${project.description.substring(0, window.innerWidth <= 768 ? 30 : 50)}${project.description.length > (window.innerWidth <= 768 ? 30 : 50) ? '...' : ''}`}
</Text>
</div>
{window.innerWidth > 768 && (
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDate(project.updated_at)}
</Text>
)}
</div>
</Card>
))}
</Space>
)}
</div>
</div>
{selectedProjectIds.length > 0 && (
<Alert
message={`已选择 ${selectedProjectIds.length} 个项目`}
type="info"
showIcon
style={{ marginTop: 8 }}
/>
)}
</Space>
</Modal>
</div>
);
}
+5 -5
View File
@@ -59,7 +59,7 @@ export default function SettingsPage() {
form.setFieldsValue({
api_provider: 'openai',
api_base_url: 'https://api.openai.com/v1',
model_name: 'gpt-4',
llm_model: 'gpt-4',
temperature: 0.7,
max_tokens: 2000,
});
@@ -96,7 +96,7 @@ export default function SettingsPage() {
api_provider: 'openai',
api_key: '',
api_base_url: 'https://api.openai.com/v1',
model_name: 'gpt-4',
llm_model: 'gpt-4',
temperature: 0.7,
max_tokens: 2000,
});
@@ -193,7 +193,7 @@ export default function SettingsPage() {
const apiKey = form.getFieldValue('api_key');
const apiBaseUrl = form.getFieldValue('api_base_url');
const provider = form.getFieldValue('api_provider');
const modelName = form.getFieldValue('model_name');
const modelName = form.getFieldValue('llm_model');
if (!apiKey || !apiBaseUrl || !provider || !modelName) {
message.warning('请先填写完整的配置信息');
@@ -208,7 +208,7 @@ export default function SettingsPage() {
api_key: apiKey,
api_base_url: apiBaseUrl,
provider: provider,
model_name: modelName
llm_model: modelName
});
setTestResult(result);
@@ -406,7 +406,7 @@ export default function SettingsPage() {
</Tooltip>
</Space>
}
name="model_name"
name="llm_model"
rules={[{ required: true, message: '请输入或选择模型名称' }]}
>
<Select
+66 -1
View File
@@ -147,7 +147,7 @@ export const settingsApi = {
getAvailableModels: (params: { api_key: string; api_base_url: string; provider: string }) =>
api.get<unknown, { provider: string; models: Array<{ value: string; label: string; description: string }>; count?: number }>('/settings/models', { params }),
testApiConnection: (params: { api_key: string; api_base_url: string; provider: string; model_name: string }) =>
testApiConnection: (params: { api_key: string; api_base_url: string; provider: string; llm_model: string }) =>
api.post<unknown, {
success: boolean;
message: string;
@@ -177,6 +177,71 @@ export const projectApi = {
exportProject: (id: string) => {
window.open(`/api/projects/${id}/export`, '_blank');
},
// 导出项目数据为JSON
exportProjectData: async (id: string, options: { include_generation_history?: boolean; include_writing_styles?: boolean }) => {
const response = await axios.post(
`/api/projects/${id}/export-data`,
options,
{
responseType: 'blob',
headers: {
'Content-Type': 'application/json',
},
}
);
// 从响应头获取文件名
const contentDisposition = response.headers['content-disposition'];
let filename = 'project_export.json';
if (contentDisposition) {
const matches = /filename\*=UTF-8''(.+)/.exec(contentDisposition);
if (matches && matches[1]) {
filename = decodeURIComponent(matches[1]);
}
}
// 创建下载链接
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
// 验证导入文件
validateImportFile: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return api.post<unknown, {
valid: boolean;
version: string;
project_name?: string;
statistics: Record<string, number>;
errors: string[];
warnings: string[];
}>('/projects/validate-import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
// 导入项目
importProject: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return api.post<unknown, {
success: boolean;
project_id?: string;
message: string;
statistics: Record<string, number>;
warnings: string[];
}>('/projects/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
};
export const outlineApi = {
+10 -2
View File
@@ -331,6 +331,7 @@ export function useChapterSync() {
let buffer = '';
let fullContent = '';
let analysisTaskId: string | undefined;
while (true) {
const { done, value } = await reader.read();
@@ -363,9 +364,13 @@ export function useChapterSync() {
} else if (message.type === 'error') {
throw new Error(message.error || '生成失败');
} else if (message.type === 'done') {
// 生成完成,保存分析任务ID
analysisTaskId = message.analysis_task_id;
// 生成完成,刷新章节数据
await refreshChapters();
return { content: fullContent, word_count: message.word_count };
} else if (message.type === 'analysis_queued') {
// 分析任务已加入队列
analysisTaskId = message.task_id;
}
}
} catch (error) {
@@ -374,7 +379,10 @@ export function useChapterSync() {
}
}
return { content: fullContent };
return {
content: fullContent,
analysis_task_id: analysisTaskId
};
} catch (error) {
console.error('AI流式生成章节内容失败:', error);
throw error;
+138 -2
View File
@@ -18,7 +18,7 @@ export interface Settings {
api_provider: string;
api_key: string;
api_base_url: string;
model_name: string;
llm_model: string;
temperature: number;
max_tokens: number;
preferences?: string;
@@ -30,7 +30,7 @@ export interface SettingsUpdate {
api_provider?: string;
api_key?: string;
api_base_url?: string;
model_name?: string;
llm_model?: string;
temperature?: number;
max_tokens?: number;
preferences?: string;
@@ -376,4 +376,140 @@ export interface ApiError {
};
};
message?: string;
}
// 章节分析任务相关类型
export interface AnalysisTask {
task_id: string;
chapter_id: string;
status: 'pending' | 'running' | 'completed' | 'failed';
progress: number;
error_message?: string;
created_at?: string;
started_at?: string;
completed_at?: string;
}
// 分析结果 - 钩子
export interface AnalysisHook {
type: string;
content: string;
strength: number;
position: string;
}
// 分析结果 - 伏笔
export interface AnalysisForeshadow {
content: string;
type: 'planted' | 'resolved';
strength: number;
subtlety: number;
reference_chapter?: number;
}
// 分析结果 - 冲突
export interface AnalysisConflict {
types: string[];
parties: string[];
level: number;
description: string;
resolution_progress: number;
}
// 分析结果 - 情感曲线
export interface AnalysisEmotionalArc {
primary_emotion: string;
intensity: number;
curve: string;
secondary_emotions: string[];
}
// 分析结果 - 角色状态
export interface AnalysisCharacterState {
character_name: string;
state_before: string;
state_after: string;
psychological_change: string;
key_event: string;
relationship_changes: Record<string, string>;
}
// 分析结果 - 情节点
export interface AnalysisPlotPoint {
content: string;
type: 'revelation' | 'conflict' | 'resolution' | 'transition';
importance: number;
impact: string;
}
// 分析结果 - 场景
export interface AnalysisScene {
location: string;
atmosphere: string;
duration: string;
}
// 分析结果 - 评分
export interface AnalysisScores {
pacing: number;
engagement: number;
coherence: number;
overall: number;
}
// 完整分析数据 - 匹配后端PlotAnalysis模型
export interface AnalysisData {
id: string;
chapter_id: string;
plot_stage: string;
conflict_level: number;
conflict_types: string[];
emotional_tone: string;
emotional_intensity: number;
hooks: AnalysisHook[];
hooks_count: number;
foreshadows: AnalysisForeshadow[];
foreshadows_planted: number;
foreshadows_resolved: number;
plot_points: AnalysisPlotPoint[];
plot_points_count: number;
character_states: AnalysisCharacterState[];
scenes?: AnalysisScene[];
pacing: string;
overall_quality_score: number;
pacing_score: number;
engagement_score: number;
coherence_score: number;
analysis_report: string;
suggestions: string[];
dialogue_ratio: number;
description_ratio: number;
created_at: string;
}
// 记忆片段
export interface StoryMemory {
id: string;
type: 'hook' | 'foreshadow' | 'plot_point' | 'character_event';
title: string;
content: string;
importance: number;
tags: string[];
is_foreshadow: 0 | 1 | 2; // 0=普通, 1=已埋下, 2=已回收
}
// 章节分析结果响应 - 匹配后端API返回
export interface ChapterAnalysisResponse {
chapter_id: string;
analysis: AnalysisData; // 注意:后端返回的是analysis而不是analysis_data
memories: StoryMemory[];
created_at: string;
}
// 手动触发分析响应
export interface TriggerAnalysisResponse {
task_id: string;
chapter_id: string;
status: string;
message: string;
}