@@ -40,6 +40,10 @@ Thumbs.db
|
||||
data/*.db
|
||||
backend/data/*.db
|
||||
|
||||
# ChromaDB数据库(不包含在镜像中,会在运行时生成)
|
||||
backend/data/chroma_db/
|
||||
|
||||
|
||||
# 日志文件
|
||||
logs/
|
||||
*.log
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
||||
+15
-2
@@ -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
@@ -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": "分析任务已创建并开始执行"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)}")
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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})>"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
|
||||
@@ -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] = []
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
86741b4e3f5cb7765a600d3a3d55a0f6a6cb443d
|
||||
+7
@@ -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
|
||||
}
|
||||
+156
@@ -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",
|
||||
}
|
||||
```
|
||||
+24
@@ -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
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"__version__": {
|
||||
"sentence_transformers": "2.0.0",
|
||||
"transformers": "4.7.0",
|
||||
"pytorch": "1.9.0+cu102"
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eaa086f0ffee582aeb45b36e34cdd1fe2d6de2bef61f8a559a1bbc9bd955917b
|
||||
size 470641600
|
||||
+14
@@ -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"
|
||||
}
|
||||
]
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"max_seq_length": 128,
|
||||
"do_lower_case": false
|
||||
}
|
||||
+1
@@ -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}}
|
||||
+1
File diff suppressed because one or more lines are too long
+1
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { useMemo, useEffect, useRef } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
// 标注数据类型
|
||||
export interface MemoryAnnotation {
|
||||
id: string;
|
||||
type: 'hook' | 'foreshadow' | 'plot_point' | 'character_event';
|
||||
title: string;
|
||||
content: string;
|
||||
importance: number;
|
||||
position: number;
|
||||
length: number;
|
||||
tags: string[];
|
||||
metadata: {
|
||||
strength?: number;
|
||||
foreshadowType?: 'planted' | 'resolved';
|
||||
relatedCharacters?: string[];
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
// 文本片段类型
|
||||
interface TextSegment {
|
||||
type: 'text' | 'annotated';
|
||||
content: string;
|
||||
annotation?: MemoryAnnotation;
|
||||
}
|
||||
|
||||
interface AnnotatedTextProps {
|
||||
content: string;
|
||||
annotations: MemoryAnnotation[];
|
||||
onAnnotationClick?: (annotation: MemoryAnnotation) => void;
|
||||
activeAnnotationId?: string;
|
||||
scrollToAnnotation?: string;
|
||||
}
|
||||
|
||||
// 类型颜色映射
|
||||
const TYPE_COLORS = {
|
||||
hook: '#ff6b6b',
|
||||
foreshadow: '#6b7bff',
|
||||
plot_point: '#51cf66',
|
||||
character_event: '#ffd93d',
|
||||
};
|
||||
|
||||
// 类型图标映射
|
||||
const TYPE_ICONS = {
|
||||
hook: '🎣',
|
||||
foreshadow: '🌟',
|
||||
plot_point: '💎',
|
||||
character_event: '👤',
|
||||
};
|
||||
|
||||
/**
|
||||
* 带标注的文本组件
|
||||
* 将记忆标注可视化地展示在章节文本中
|
||||
*/
|
||||
const AnnotatedText: React.FC<AnnotatedTextProps> = ({
|
||||
content,
|
||||
annotations,
|
||||
onAnnotationClick,
|
||||
activeAnnotationId,
|
||||
scrollToAnnotation,
|
||||
}) => {
|
||||
const annotationRefs = useRef<Record<string, HTMLSpanElement | null>>({});
|
||||
|
||||
// 当需要滚动到特定标注时
|
||||
useEffect(() => {
|
||||
if (scrollToAnnotation && annotationRefs.current[scrollToAnnotation]) {
|
||||
const element = annotationRefs.current[scrollToAnnotation];
|
||||
element?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
}, [scrollToAnnotation]);
|
||||
// 处理标注重叠和排序
|
||||
const processedAnnotations = useMemo(() => {
|
||||
if (!annotations || annotations.length === 0) {
|
||||
console.log('AnnotatedText: 没有标注数据');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`AnnotatedText: 收到${annotations.length}个标注,内容长度${content.length}`);
|
||||
|
||||
// 过滤掉无效位置的标注
|
||||
const validAnnotations = annotations.filter(
|
||||
(a) => a.position >= 0 && a.position < content.length
|
||||
);
|
||||
|
||||
const invalidCount = annotations.length - validAnnotations.length;
|
||||
if (invalidCount > 0) {
|
||||
console.warn(`AnnotatedText: ${invalidCount}个标注位置无效,有效标注${validAnnotations.length}个`);
|
||||
console.log('无效标注:', annotations.filter(a => a.position < 0 || a.position >= content.length));
|
||||
}
|
||||
|
||||
// 按位置排序
|
||||
return validAnnotations.sort((a, b) => a.position - b.position);
|
||||
}, [annotations, content]);
|
||||
|
||||
// 将文本分割为带标注的片段
|
||||
const segments = useMemo(() => {
|
||||
if (processedAnnotations.length === 0) {
|
||||
return [{ type: 'text' as const, content }];
|
||||
}
|
||||
|
||||
const result: TextSegment[] = [];
|
||||
let lastPos = 0;
|
||||
|
||||
for (const annotation of processedAnnotations) {
|
||||
const { position, length } = annotation;
|
||||
|
||||
// 添加普通文本片段
|
||||
if (position > lastPos) {
|
||||
result.push({
|
||||
type: 'text',
|
||||
content: content.slice(lastPos, position),
|
||||
});
|
||||
}
|
||||
|
||||
// 添加标注片段
|
||||
const annotatedContent = content.slice(
|
||||
position,
|
||||
position + (length > 0 ? length : 30) // 如果没有长度,默认30字符
|
||||
);
|
||||
|
||||
result.push({
|
||||
type: 'annotated',
|
||||
content: annotatedContent,
|
||||
annotation,
|
||||
});
|
||||
|
||||
lastPos = position + (length > 0 ? length : 30);
|
||||
}
|
||||
|
||||
// 添加剩余文本
|
||||
if (lastPos < content.length) {
|
||||
result.push({
|
||||
type: 'text',
|
||||
content: content.slice(lastPos),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [content, processedAnnotations]);
|
||||
|
||||
// 渲染标注片段
|
||||
const renderAnnotatedSegment = (segment: TextSegment, index: number) => {
|
||||
if (segment.type === 'text') {
|
||||
return <span key={index}>{segment.content}</span>;
|
||||
}
|
||||
|
||||
const { annotation } = segment;
|
||||
if (!annotation) return null;
|
||||
|
||||
const color = TYPE_COLORS[annotation.type];
|
||||
const icon = TYPE_ICONS[annotation.type];
|
||||
const isActive = activeAnnotationId === annotation.id;
|
||||
|
||||
// 工具提示内容
|
||||
const tooltipContent = (
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>
|
||||
{icon} {annotation.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.9 }}>
|
||||
{annotation.content.slice(0, 100)}
|
||||
{annotation.content.length > 100 ? '...' : ''}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.7 }}>
|
||||
重要性: {(annotation.importance * 10).toFixed(1)}/10
|
||||
</div>
|
||||
{annotation.tags && annotation.tags.length > 0 && (
|
||||
<div style={{ marginTop: 4, fontSize: 11 }}>
|
||||
{annotation.tags.map((tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 3,
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip key={index} title={tooltipContent} placement="top">
|
||||
<span
|
||||
ref={(el) => {
|
||||
if (annotation) {
|
||||
annotationRefs.current[annotation.id] = el;
|
||||
}
|
||||
}}
|
||||
data-annotation-id={annotation?.id}
|
||||
className={`annotated-text ${isActive ? 'active' : ''}`}
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderBottom: `2px solid ${color}`,
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isActive ? `${color}22` : 'transparent',
|
||||
transition: 'all 0.2s',
|
||||
padding: '2px 0',
|
||||
}}
|
||||
onClick={() => onAnnotationClick?.(annotation)}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = `${color}33`;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = isActive
|
||||
? `${color}22`
|
||||
: 'transparent';
|
||||
}}
|
||||
>
|
||||
{segment.content}
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontSize: 14,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
lineHeight: 2,
|
||||
fontSize: 16,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{segments.map((segment, index) => renderAnnotatedSegment(segment, index))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnotatedText;
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user