@@ -40,6 +40,10 @@ Thumbs.db
|
|||||||
data/*.db
|
data/*.db
|
||||||
backend/data/*.db
|
backend/data/*.db
|
||||||
|
|
||||||
|
# ChromaDB数据库(不包含在镜像中,会在运行时生成)
|
||||||
|
backend/data/chroma_db/
|
||||||
|
|
||||||
|
|
||||||
# 日志文件
|
# 日志文件
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.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 ./
|
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/
|
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
|
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
|
COPY backend/.env.example ./.env.example
|
||||||
@@ -63,6 +70,12 @@ ENV PYTHONUNBUFFERED=1
|
|||||||
ENV APP_HOST=0.0.0.0
|
ENV APP_HOST=0.0.0.0
|
||||||
ENV APP_PORT=8000
|
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 \
|
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
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||||
|
|||||||
+734
-6
@@ -1,11 +1,13 @@
|
|||||||
"""章节管理API"""
|
"""章节管理API"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Query
|
from fastapi import APIRouter, Depends, HTTPException, Request, Query, BackgroundTasks
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from asyncio import Queue, Lock
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.chapter import Chapter
|
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.character import Character
|
||||||
from app.models.generation_history import GenerationHistory
|
from app.models.generation_history import GenerationHistory
|
||||||
from app.models.writing_style import WritingStyle
|
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 (
|
from app.schemas.chapter import (
|
||||||
ChapterCreate,
|
ChapterCreate,
|
||||||
ChapterUpdate,
|
ChapterUpdate,
|
||||||
@@ -23,12 +27,25 @@ from app.schemas.chapter import (
|
|||||||
)
|
)
|
||||||
from app.services.ai_service import AIService
|
from app.services.ai_service import AIService
|
||||||
from app.services.prompt_service import prompt_service
|
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.logger import get_logger
|
||||||
from app.api.settings import get_user_ai_service
|
from app.api.settings import get_user_ai_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/chapters", tags=["章节管理"])
|
router = APIRouter(prefix="/chapters", tags=["章节管理"])
|
||||||
logger = get_logger(__name__)
|
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="创建章节")
|
@router.post("", response_model=ChapterResponse, summary="创建章节")
|
||||||
async def create_chapter(
|
async def create_chapter(
|
||||||
@@ -101,6 +118,63 @@ async def get_chapter(
|
|||||||
return 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="更新章节")
|
@router.put("/{chapter_id}", response_model=ChapterResponse, summary="更新章节")
|
||||||
async def update_chapter(
|
async def update_chapter(
|
||||||
chapter_id: str,
|
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创作章节内容(流式)")
|
@router.post("/{chapter_id}/generate-stream", summary="AI创作章节内容(流式)")
|
||||||
async def generate_chapter_content_stream(
|
async def generate_chapter_content_stream(
|
||||||
chapter_id: str,
|
chapter_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
generate_request: ChapterGenerateRequest = ChapterGenerateRequest(),
|
generate_request: ChapterGenerateRequest = ChapterGenerateRequest(),
|
||||||
user_ai_service: AIService = Depends(get_user_ai_service)
|
user_ai_service: AIService = Depends(get_user_ai_service)
|
||||||
):
|
):
|
||||||
@@ -301,6 +642,9 @@ async def generate_chapter_content_stream(
|
|||||||
# 在生成器内部创建独立的数据库会话
|
# 在生成器内部创建独立的数据库会话
|
||||||
db_session = None
|
db_session = None
|
||||||
db_committed = False
|
db_committed = False
|
||||||
|
# 获取当前用户ID(在生成器外部就需要)
|
||||||
|
current_user_id = getattr(request.state, "user_id", "system")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 创建新的数据库会话
|
# 创建新的数据库会话
|
||||||
async for db_session in get_db(request):
|
async for db_session in get_db(request):
|
||||||
@@ -396,11 +740,42 @@ async def generate_chapter_content_stream(
|
|||||||
previous_content += recent_content
|
previous_content += recent_content
|
||||||
|
|
||||||
logger.info(f"构建前置上下文:{len(early_chapters)}章摘要 + {len(recent_chapters)}章完整内容")
|
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"
|
yield f"data: {json.dumps({'type': 'start', 'message': '开始AI创作...'}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
# 根据是否有前置内容选择不同的提示词,并应用写作风格
|
# 根据是否有前置内容选择不同的提示词,并应用写作风格和记忆增强
|
||||||
if previous_content:
|
if previous_content:
|
||||||
prompt = prompt_service.get_chapter_generation_with_context_prompt(
|
prompt = prompt_service.get_chapter_generation_with_context_prompt(
|
||||||
title=project.title,
|
title=project.title,
|
||||||
@@ -418,7 +793,8 @@ async def generate_chapter_content_stream(
|
|||||||
chapter_title=current_chapter.title,
|
chapter_title=current_chapter.title,
|
||||||
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲',
|
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲',
|
||||||
style_content=style_content,
|
style_content=style_content,
|
||||||
target_word_count=target_word_count
|
target_word_count=target_word_count,
|
||||||
|
memory_context=memory_context
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
prompt = prompt_service.get_chapter_generation_prompt(
|
prompt = prompt_service.get_chapter_generation_prompt(
|
||||||
@@ -436,7 +812,8 @@ async def generate_chapter_content_stream(
|
|||||||
chapter_title=current_chapter.title,
|
chapter_title=current_chapter.title,
|
||||||
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲',
|
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲',
|
||||||
style_content=style_content,
|
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}")
|
logger.info(f"开始AI流式创作章节 {chapter_id}")
|
||||||
@@ -474,8 +851,50 @@ async def generate_chapter_content_stream(
|
|||||||
|
|
||||||
logger.info(f"成功创作章节 {chapter_id},共 {new_word_count} 字")
|
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循环
|
break # 退出async for db_session循环
|
||||||
|
|
||||||
@@ -527,3 +946,312 @@ async def generate_chapter_content_stream(
|
|||||||
"X-Accel-Buffering": "no"
|
"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"""
|
"""大纲管理API"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, delete
|
from sqlalchemy import select, func, delete
|
||||||
from typing import List, AsyncGenerator, Dict, Any
|
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.ai_service import AIService
|
||||||
from app.services.prompt_service import prompt_service
|
from app.services.prompt_service import prompt_service
|
||||||
|
from app.services.memory_service import memory_service
|
||||||
from app.logger import get_logger
|
from app.logger import get_logger
|
||||||
from app.api.settings import get_user_ai_service
|
from app.api.settings import get_user_ai_service
|
||||||
from app.utils.sse_response import SSEResponse, create_sse_response
|
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生成/续写大纲")
|
@router.post("/generate", response_model=OutlineListResponse, summary="AI生成/续写大纲")
|
||||||
async def generate_outline(
|
async def generate_outline(
|
||||||
request: OutlineGenerateRequest,
|
request: OutlineGenerateRequest,
|
||||||
|
http_request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user_ai_service: AIService = Depends(get_user_ai_service)
|
user_ai_service: AIService = Depends(get_user_ai_service)
|
||||||
):
|
):
|
||||||
@@ -377,8 +379,10 @@ async def generate_outline(
|
|||||||
detail="续写模式需要已有大纲,当前项目没有大纲"
|
detail="续写模式需要已有大纲,当前项目没有大纲"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 获取用户ID用于记忆检索
|
||||||
|
user_id = getattr(http_request.state, "user_id", "system")
|
||||||
return await _continue_outline(
|
return await _continue_outline(
|
||||||
request, project, existing_outlines, db, user_ai_service
|
request, project, existing_outlines, db, user_ai_service, user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -478,9 +482,10 @@ async def _continue_outline(
|
|||||||
project: Project,
|
project: Project,
|
||||||
existing_outlines: List[Outline],
|
existing_outlines: List[Outline],
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_ai_service: AIService
|
user_ai_service: AIService,
|
||||||
|
user_id: str = "system"
|
||||||
) -> OutlineListResponse:
|
) -> OutlineListResponse:
|
||||||
"""续写大纲 - 分批生成,每批5章"""
|
"""续写大纲 - 分批生成,每批5章(记忆增强版)"""
|
||||||
logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章")
|
logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章")
|
||||||
|
|
||||||
# 分析已有大纲
|
# 分析已有大纲
|
||||||
@@ -545,7 +550,25 @@ async def _continue_outline(
|
|||||||
for o in latest_outlines
|
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(
|
prompt = prompt_service.get_outline_continue_prompt(
|
||||||
title=project.title,
|
title=project.title,
|
||||||
theme=request.theme or project.theme or "未设定",
|
theme=request.theme or project.theme or "未设定",
|
||||||
@@ -563,7 +586,8 @@ async def _continue_outline(
|
|||||||
plot_stage_instruction=stage_instruction,
|
plot_stage_instruction=stage_instruction,
|
||||||
start_chapter=current_start_chapter,
|
start_chapter=current_start_chapter,
|
||||||
story_direction=request.story_direction or "自然延续",
|
story_direction=request.story_direction or "自然延续",
|
||||||
requirements=request.requirements or ""
|
requirements=request.requirements or "",
|
||||||
|
memory_context=memory_context
|
||||||
)
|
)
|
||||||
|
|
||||||
# 调用AI生成当前批次
|
# 调用AI生成当前批次
|
||||||
@@ -834,9 +858,10 @@ async def new_outline_generator(
|
|||||||
async def continue_outline_generator(
|
async def continue_outline_generator(
|
||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_ai_service: AIService
|
user_ai_service: AIService,
|
||||||
|
user_id: str = "system"
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""大纲续写SSE生成器 - 分批生成,推送进度"""
|
"""大纲续写SSE生成器 - 分批生成,推送进度(记忆增强版)"""
|
||||||
db_committed = False
|
db_committed = False
|
||||||
try:
|
try:
|
||||||
yield await SSEResponse.send_progress("开始续写大纲...", 5)
|
yield await SSEResponse.send_progress("开始续写大纲...", 5)
|
||||||
@@ -940,12 +965,32 @@ async def continue_outline_generator(
|
|||||||
for o in latest_outlines
|
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(
|
yield await SSEResponse.send_progress(
|
||||||
f"🤖 调用AI生成第{str(batch_num + 1)}批...",
|
f" 调用AI生成第{str(batch_num + 1)}批...",
|
||||||
batch_progress + 5
|
batch_progress + 5
|
||||||
)
|
)
|
||||||
|
|
||||||
# 使用标准续写提示词模板
|
# 使用标准续写提示词模板(支持记忆增强)
|
||||||
prompt = prompt_service.get_outline_continue_prompt(
|
prompt = prompt_service.get_outline_continue_prompt(
|
||||||
title=project.title,
|
title=project.title,
|
||||||
theme=data.get("theme") or project.theme or "未设定",
|
theme=data.get("theme") or project.theme or "未设定",
|
||||||
@@ -963,7 +1008,8 @@ async def continue_outline_generator(
|
|||||||
plot_stage_instruction=stage_instruction,
|
plot_stage_instruction=stage_instruction,
|
||||||
start_chapter=current_start_chapter,
|
start_chapter=current_start_chapter,
|
||||||
story_direction=data.get("story_direction", "自然延续"),
|
story_direction=data.get("story_direction", "自然延续"),
|
||||||
requirements=data.get("requirements", "")
|
requirements=data.get("requirements", ""),
|
||||||
|
memory_context=memory_context
|
||||||
)
|
)
|
||||||
|
|
||||||
# 调用AI生成当前批次
|
# 调用AI生成当前批次
|
||||||
@@ -1062,6 +1108,7 @@ async def continue_outline_generator(
|
|||||||
@router.post("/generate-stream", summary="AI生成/续写大纲(SSE流式)")
|
@router.post("/generate-stream", summary="AI生成/续写大纲(SSE流式)")
|
||||||
async def generate_outline_stream(
|
async def generate_outline_stream(
|
||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user_ai_service: AIService = Depends(get_user_ai_service)
|
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"
|
mode = "continue" if existing_outlines else "new"
|
||||||
logger.info(f"自动判断模式:{'续写' if existing_outlines else '新建'}")
|
logger.info(f"自动判断模式:{'续写' if existing_outlines else '新建'}")
|
||||||
|
|
||||||
|
# 获取用户ID
|
||||||
|
user_id = getattr(request.state, "user_id", "system")
|
||||||
|
|
||||||
# 根据模式选择生成器
|
# 根据模式选择生成器
|
||||||
if mode == "new":
|
if mode == "new":
|
||||||
return create_sse_response(new_outline_generator(data, db, user_ai_service))
|
return create_sse_response(new_outline_generator(data, db, user_ai_service))
|
||||||
@@ -1120,7 +1170,7 @@ async def generate_outline_stream(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
detail="续写模式需要已有大纲,当前项目没有大纲"
|
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:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|||||||
+174
-2
@@ -1,9 +1,11 @@
|
|||||||
"""项目管理API"""
|
"""项目管理API"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, delete
|
from sqlalchemy import select, func, delete
|
||||||
from typing import List
|
from typing import List
|
||||||
|
import json
|
||||||
|
from urllib.parse import quote
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.models.character import Character
|
from app.models.character import Character
|
||||||
@@ -17,6 +19,12 @@ from app.schemas.project import (
|
|||||||
ProjectResponse,
|
ProjectResponse,
|
||||||
ProjectListResponse
|
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.logger import get_logger
|
||||||
from app.utils.data_consistency import (
|
from app.utils.data_consistency import (
|
||||||
run_full_data_consistency_check,
|
run_full_data_consistency_check,
|
||||||
@@ -412,4 +420,168 @@ async def fix_project_member_counts(
|
|||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"修复成员计数失败: {str(e)}", exc_info=True)
|
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_provider": app_settings.default_ai_provider,
|
||||||
"api_key": app_settings.openai_api_key or app_settings.anthropic_api_key or "",
|
"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 "",
|
"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,
|
"temperature": app_settings.default_temperature,
|
||||||
"max_tokens": app_settings.default_max_tokens,
|
"max_tokens": app_settings.default_max_tokens,
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ async def get_user_ai_service(
|
|||||||
api_provider=settings.api_provider,
|
api_provider=settings.api_provider,
|
||||||
api_key=settings.api_key,
|
api_key=settings.api_key,
|
||||||
api_base_url=settings.api_base_url or "",
|
api_base_url=settings.api_base_url or "",
|
||||||
model_name=settings.model_name,
|
model_name=settings.llm_model,
|
||||||
temperature=settings.temperature,
|
temperature=settings.temperature,
|
||||||
max_tokens=settings.max_tokens
|
max_tokens=settings.max_tokens
|
||||||
)
|
)
|
||||||
@@ -305,7 +305,7 @@ class ApiTestRequest(BaseModel):
|
|||||||
api_key: str
|
api_key: str
|
||||||
api_base_url: str
|
api_base_url: str
|
||||||
provider: str
|
provider: str
|
||||||
model_name: str
|
llm_model: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test")
|
@router.post("/test")
|
||||||
@@ -322,7 +322,7 @@ async def test_api_connection(data: ApiTestRequest):
|
|||||||
api_key = data.api_key
|
api_key = data.api_key
|
||||||
api_base_url = data.api_base_url
|
api_base_url = data.api_base_url
|
||||||
provider = data.provider
|
provider = data.provider
|
||||||
model_name = data.model_name
|
llm_model = data.llm_model
|
||||||
import time
|
import time
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -333,7 +333,7 @@ async def test_api_connection(data: ApiTestRequest):
|
|||||||
api_provider=provider,
|
api_provider=provider,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
api_base_url=api_base_url,
|
api_base_url=api_base_url,
|
||||||
default_model=model_name,
|
default_model=llm_model,
|
||||||
default_temperature=0.7,
|
default_temperature=0.7,
|
||||||
default_max_tokens=100
|
default_max_tokens=100
|
||||||
)
|
)
|
||||||
@@ -343,13 +343,13 @@ async def test_api_connection(data: ApiTestRequest):
|
|||||||
|
|
||||||
logger.info(f"🧪 开始测试 API 连接")
|
logger.info(f"🧪 开始测试 API 连接")
|
||||||
logger.info(f" - 提供商: {provider}")
|
logger.info(f" - 提供商: {provider}")
|
||||||
logger.info(f" - 模型: {model_name}")
|
logger.info(f" - 模型: {llm_model}")
|
||||||
logger.info(f" - Base URL: {api_base_url}")
|
logger.info(f" - Base URL: {api_base_url}")
|
||||||
|
|
||||||
response = await test_service.generate_text(
|
response = await test_service.generate_text(
|
||||||
prompt=test_prompt,
|
prompt=test_prompt,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
model=model_name,
|
model=llm_model,
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
max_tokens=8000
|
max_tokens=8000
|
||||||
)
|
)
|
||||||
@@ -366,7 +366,7 @@ async def test_api_connection(data: ApiTestRequest):
|
|||||||
"message": "API 连接测试成功",
|
"message": "API 连接测试成功",
|
||||||
"response_time_ms": response_time,
|
"response_time_ms": response_time,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"model": model_name,
|
"model": llm_model,
|
||||||
"response_preview": response[:100] if response and len(response) > 100 else response,
|
"response_preview": response[:100] if response and len(response) > 100 else response,
|
||||||
"details": {
|
"details": {
|
||||||
"api_available": True,
|
"api_available": True,
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ logger = get_logger(__name__)
|
|||||||
# 创建基类
|
# 创建基类
|
||||||
Base = declarative_base()
|
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] = {}
|
_engine_cache: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -114,7 +114,7 @@ async def db_session_stats():
|
|||||||
from app.api import (
|
from app.api import (
|
||||||
projects, outlines, characters, chapters,
|
projects, outlines, characters, chapters,
|
||||||
wizard_stream, relationships, organizations,
|
wizard_stream, relationships, organizations,
|
||||||
auth, users, settings, writing_styles
|
auth, users, settings, writing_styles, memories
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth.router, prefix="/api")
|
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(relationships.router, prefix="/api")
|
||||||
app.include_router(organizations.router, prefix="/api")
|
app.include_router(organizations.router, prefix="/api")
|
||||||
app.include_router(writing_styles.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"
|
static_dir = Path(__file__).parent.parent / "static"
|
||||||
if static_dir.exists():
|
if static_dir.exists():
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from app.models.relationship import (
|
|||||||
Organization,
|
Organization,
|
||||||
OrganizationMember
|
OrganizationMember
|
||||||
)
|
)
|
||||||
|
from app.models.memory import StoryMemory, PlotAnalysis
|
||||||
|
from app.models.analysis_task import AnalysisTask
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Project",
|
"Project",
|
||||||
@@ -27,4 +29,7 @@ __all__ = [
|
|||||||
"CharacterRelationship",
|
"CharacterRelationship",
|
||||||
"Organization",
|
"Organization",
|
||||||
"OrganizationMember",
|
"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_provider = Column(String(50), default="openai", comment="API提供商")
|
||||||
api_key = Column(String(500), comment="API密钥")
|
api_key = Column(String(500), comment="API密钥")
|
||||||
api_base_url = 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="温度参数")
|
temperature = Column(Float, default=0.7, comment="温度参数")
|
||||||
max_tokens = Column(Integer, default=2000, comment="最大token数")
|
max_tokens = Column(Integer, default=2000, comment="最大token数")
|
||||||
preferences = Column(Text, comment="其他偏好设置(JSON)")
|
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_provider: Optional[str] = Field(default="openai", description="API提供商")
|
||||||
api_key: Optional[str] = Field(default=None, description="API密钥")
|
api_key: Optional[str] = Field(default=None, description="API密钥")
|
||||||
api_base_url: 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="温度参数")
|
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数")
|
max_tokens: Optional[int] = Field(default=2000, ge=1, description="最大token数")
|
||||||
preferences: Optional[str] = Field(default=None, description="其他偏好设置(JSON)")
|
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}个章节对象
|
2. 数组中要包含{chapter_count}个章节对象
|
||||||
3. 文本中不要使用中文引号(""),改用【】或《》"""
|
3. 文本中不要使用中文引号(""),改用【】或《》"""
|
||||||
|
|
||||||
# 大纲续写提示词
|
# 大纲续写提示词(记忆增强版)
|
||||||
OUTLINE_CONTINUE_GENERATION = """你是一位经验丰富的小说作家和编剧。请基于以下信息续写小说大纲:
|
OUTLINE_CONTINUE_GENERATION = """你是一位经验丰富的小说作家和编剧。请基于以下信息续写小说大纲:
|
||||||
|
|
||||||
【项目信息】
|
【项目信息】
|
||||||
@@ -340,6 +340,11 @@ class PromptService:
|
|||||||
【最近剧情】
|
【最近剧情】
|
||||||
{recent_plot}
|
{recent_plot}
|
||||||
|
|
||||||
|
【🧠 智能记忆系统 - 续写参考】
|
||||||
|
以下是从故事记忆库中检索到的相关信息,请在续写大纲时参考:
|
||||||
|
|
||||||
|
{memory_context}
|
||||||
|
|
||||||
【续写指导】
|
【续写指导】
|
||||||
- 当前情节阶段:{plot_stage_instruction}
|
- 当前情节阶段:{plot_stage_instruction}
|
||||||
- 起始章节编号:第{start_chapter}章
|
- 起始章节编号:第{start_chapter}章
|
||||||
@@ -348,10 +353,12 @@ class PromptService:
|
|||||||
|
|
||||||
请生成第{start_chapter}章到第{end_chapter}章的大纲。
|
请生成第{start_chapter}章到第{end_chapter}章的大纲。
|
||||||
要求:
|
要求:
|
||||||
- 与前文自然衔接,保持故事连贯性
|
- **剧情连贯性**:与前文自然衔接,保持故事连贯性
|
||||||
- 遵循情节阶段的发展要求
|
- **记忆参考**:适当参考记忆系统中的伏笔、钩子和情节点
|
||||||
- 保持与已有章节相同的风格和详细程度
|
- **伏笔回收**:可以考虑回收未完结的伏笔,制造呼应
|
||||||
- 推进角色成长和情节发展
|
- **角色发展**:遵循角色在前文中的成长轨迹
|
||||||
|
- **情节阶段**:遵循情节阶段的发展要求
|
||||||
|
- **风格一致**:保持与已有章节相同的风格和详细程度
|
||||||
|
|
||||||
**重要格式要求:**
|
**重要格式要求:**
|
||||||
1. 只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字
|
1. 只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字
|
||||||
@@ -465,7 +472,7 @@ class PromptService:
|
|||||||
|
|
||||||
请直接输出章节正文内容,不要包含章节标题和其他说明文字。"""
|
请直接输出章节正文内容,不要包含章节标题和其他说明文字。"""
|
||||||
|
|
||||||
# 章节完整创作提示词(带前置章节上下文)
|
# 章节完整创作提示词(带前置章节上下文和记忆增强)
|
||||||
CHAPTER_GENERATION_WITH_CONTEXT = """你是一位专业的小说作家。请根据以下信息创作本章内容:
|
CHAPTER_GENERATION_WITH_CONTEXT = """你是一位专业的小说作家。请根据以下信息创作本章内容:
|
||||||
|
|
||||||
项目信息:
|
项目信息:
|
||||||
@@ -489,6 +496,11 @@ class PromptService:
|
|||||||
【已完成的前置章节内容】
|
【已完成的前置章节内容】
|
||||||
{previous_content}
|
{previous_content}
|
||||||
|
|
||||||
|
【🧠 智能记忆系统 - 重要参考】
|
||||||
|
以下是从故事记忆库中检索到的相关信息,请在创作时适当参考和呼应:
|
||||||
|
|
||||||
|
{memory_context}
|
||||||
|
|
||||||
本章信息:
|
本章信息:
|
||||||
- 章节序号:第{chapter_number}章
|
- 章节序号:第{chapter_number}章
|
||||||
- 章节标题:{chapter_title}
|
- 章节标题:{chapter_title}
|
||||||
@@ -518,8 +530,15 @@ class PromptService:
|
|||||||
- 体现世界观特色
|
- 体现世界观特色
|
||||||
|
|
||||||
5. **承上启下**:
|
5. **承上启下**:
|
||||||
- 开头自然衔接上一章结尾
|
- 开头自然衔接上一章结尾
|
||||||
- 结尾为下一章做好铺垫
|
- 结尾为下一章做好铺垫
|
||||||
|
|
||||||
|
6. **记忆系统使用指南**:
|
||||||
|
- **最近章节记忆**:保持情节连贯,注意角色状态和剧情发展
|
||||||
|
- **语义相关记忆**:参考相似情节的处理方式
|
||||||
|
- **未完结伏笔**:适当时机可以回收伏笔,制造呼应效果
|
||||||
|
- **角色状态记忆**:确保角色行为符合其发展轨迹
|
||||||
|
- **重要情节点**:与关键剧情保持一致
|
||||||
|
|
||||||
请直接输出章节正文内容,不要包含章节标题和其他说明文字。"""
|
请直接输出章节正文内容,不要包含章节标题和其他说明文字。"""
|
||||||
|
|
||||||
@@ -746,14 +765,26 @@ class PromptService:
|
|||||||
characters_info: str, outlines_context: str,
|
characters_info: str, outlines_context: str,
|
||||||
chapter_number: int, chapter_title: str,
|
chapter_number: int, chapter_title: str,
|
||||||
chapter_outline: str, style_content: str = "",
|
chapter_outline: str, style_content: str = "",
|
||||||
target_word_count: int = 3000) -> str:
|
target_word_count: int = 3000,
|
||||||
|
memory_context: dict = None) -> str:
|
||||||
"""
|
"""
|
||||||
获取章节完整创作提示词
|
获取章节完整创作提示词
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
style_content: 写作风格要求内容,如果提供则会追加到提示词中
|
style_content: 写作风格要求内容,如果提供则会追加到提示词中
|
||||||
target_word_count: 目标字数,默认3000字
|
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(
|
base_prompt = cls.format_prompt(
|
||||||
cls.CHAPTER_GENERATION,
|
cls.CHAPTER_GENERATION,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -772,6 +803,13 @@ class PromptService:
|
|||||||
target_word_count=target_word_count
|
target_word_count=target_word_count
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 插入记忆上下文
|
||||||
|
if memory_text:
|
||||||
|
base_prompt = base_prompt.replace(
|
||||||
|
"本章信息:",
|
||||||
|
memory_text + "\n\n本章信息:"
|
||||||
|
)
|
||||||
|
|
||||||
# 如果有风格要求,应用到提示词中
|
# 如果有风格要求,应用到提示词中
|
||||||
if style_content:
|
if style_content:
|
||||||
return WritingStyleManager.apply_style_to_prompt(base_prompt, style_content)
|
return WritingStyleManager.apply_style_to_prompt(base_prompt, style_content)
|
||||||
@@ -786,14 +824,27 @@ class PromptService:
|
|||||||
previous_content: str, chapter_number: int,
|
previous_content: str, chapter_number: int,
|
||||||
chapter_title: str, chapter_outline: str,
|
chapter_title: str, chapter_outline: str,
|
||||||
style_content: str = "",
|
style_content: str = "",
|
||||||
target_word_count: int = 3000) -> str:
|
target_word_count: int = 3000,
|
||||||
|
memory_context: dict = None) -> str:
|
||||||
"""
|
"""
|
||||||
获取章节完整创作提示词(带前置章节上下文)
|
获取章节完整创作提示词(带前置章节上下文和记忆增强)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
style_content: 写作风格要求内容,如果提供则会追加到提示词中
|
style_content: 写作风格要求内容,如果提供则会追加到提示词中
|
||||||
target_word_count: 目标字数,默认3000字
|
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(
|
base_prompt = cls.format_prompt(
|
||||||
cls.CHAPTER_GENERATION_WITH_CONTEXT,
|
cls.CHAPTER_GENERATION_WITH_CONTEXT,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -810,7 +861,8 @@ class PromptService:
|
|||||||
chapter_number=chapter_number,
|
chapter_number=chapter_number,
|
||||||
chapter_title=chapter_title,
|
chapter_title=chapter_title,
|
||||||
chapter_outline=chapter_outline,
|
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,
|
current_chapter_count: int, all_chapters_brief: str,
|
||||||
recent_plot: str, plot_stage_instruction: str,
|
recent_plot: str, plot_stage_instruction: str,
|
||||||
start_chapter: int, story_direction: str,
|
start_chapter: int, story_direction: str,
|
||||||
requirements: str = "") -> str:
|
requirements: str = "",
|
||||||
"""获取大纲续写提示词"""
|
memory_context: dict = None) -> str:
|
||||||
|
"""获取大纲续写提示词(支持记忆增强)"""
|
||||||
end_chapter = start_chapter + chapter_count - 1
|
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(
|
return cls.format_prompt(
|
||||||
cls.OUTLINE_CONTINUE_GENERATION,
|
cls.OUTLINE_CONTINUE_GENERATION,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -861,7 +926,8 @@ class PromptService:
|
|||||||
start_chapter=start_chapter,
|
start_chapter=start_chapter,
|
||||||
end_chapter=end_chapter,
|
end_chapter=end_chapter,
|
||||||
story_direction=story_direction,
|
story_direction=story_direction,
|
||||||
requirements=requirements or "无特殊要求"
|
requirements=requirements or "无特殊要求",
|
||||||
|
memory_context=memory_text
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@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
|
pydantic-settings==2.1.0
|
||||||
|
|
||||||
# AI服务
|
# AI服务
|
||||||
openai==1.10.0
|
openai==2.7.0
|
||||||
anthropic==0.18.0
|
anthropic==0.72.0
|
||||||
|
|
||||||
# 工具库
|
# 工具库
|
||||||
httpx==0.26.0
|
httpx==0.28.1
|
||||||
python-dotenv==1.0.0
|
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 Relationships from './pages/Relationships';
|
||||||
import Organizations from './pages/Organizations';
|
import Organizations from './pages/Organizations';
|
||||||
import Chapters from './pages/Chapters';
|
import Chapters from './pages/Chapters';
|
||||||
|
import ChapterReader from './pages/ChapterReader';
|
||||||
|
import ChapterAnalysis from './pages/ChapterAnalysis';
|
||||||
import WritingStyles from './pages/WritingStyles';
|
import WritingStyles from './pages/WritingStyles';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
// import Polish from './pages/Polish';
|
// import Polish from './pages/Polish';
|
||||||
@@ -34,6 +36,7 @@ function App() {
|
|||||||
<Route path="/" element={<ProtectedRoute><ProjectList /></ProtectedRoute>} />
|
<Route path="/" element={<ProtectedRoute><ProjectList /></ProtectedRoute>} />
|
||||||
<Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></ProtectedRoute>} />
|
<Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></ProtectedRoute>} />
|
||||||
<Route path="/settings" element={<ProtectedRoute><Settings /></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 path="/project/:projectId" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>}>
|
||||||
<Route index element={<Navigate to="world-setting" replace />} />
|
<Route index element={<Navigate to="world-setting" replace />} />
|
||||||
<Route path="world-setting" element={<WorldSetting />} />
|
<Route path="world-setting" element={<WorldSetting />} />
|
||||||
@@ -42,6 +45,7 @@ function App() {
|
|||||||
<Route path="relationships" element={<Relationships />} />
|
<Route path="relationships" element={<Relationships />} />
|
||||||
<Route path="organizations" element={<Organizations />} />
|
<Route path="organizations" element={<Organizations />} />
|
||||||
<Route path="chapters" element={<Chapters />} />
|
<Route path="chapters" element={<Chapters />} />
|
||||||
|
<Route path="chapter-analysis" element={<ChapterAnalysis />} />
|
||||||
<Route path="writing-styles" element={<WritingStyles />} />
|
<Route path="writing-styles" element={<WritingStyles />} />
|
||||||
{/* <Route path="polish" element={<Polish />} /> */}
|
{/* <Route path="polish" element={<Polish />} /> */}
|
||||||
</Route>
|
</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 { useState, useEffect, useRef } from 'react';
|
||||||
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber } from 'antd';
|
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 { useStore } from '../store';
|
||||||
import { useChapterSync } from '../store/hooks';
|
import { useChapterSync } from '../store/hooks';
|
||||||
import { projectApi, writingStyleApi } from '../services/api';
|
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 { cardStyles } from '../components/CardStyles';
|
||||||
|
import ChapterAnalysis from '../components/ChapterAnalysis';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
@@ -23,6 +24,11 @@ export default function Chapters() {
|
|||||||
const [writingStyles, setWritingStyles] = useState<WritingStyle[]>([]);
|
const [writingStyles, setWritingStyles] = useState<WritingStyle[]>([]);
|
||||||
const [selectedStyleId, setSelectedStyleId] = useState<number | undefined>();
|
const [selectedStyleId, setSelectedStyleId] = useState<number | undefined>();
|
||||||
const [targetWordCount, setTargetWordCount] = useState<number>(3000);
|
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(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
@@ -43,10 +49,96 @@ export default function Chapters() {
|
|||||||
if (currentProject?.id) {
|
if (currentProject?.id) {
|
||||||
refreshChapters();
|
refreshChapters();
|
||||||
loadWritingStyles();
|
loadWritingStyles();
|
||||||
|
loadAnalysisTasks();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentProject?.id]);
|
}, [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 () => {
|
const loadWritingStyles = async () => {
|
||||||
if (!currentProject?.id) return;
|
if (!currentProject?.id) return;
|
||||||
|
|
||||||
@@ -159,7 +251,7 @@ export default function Chapters() {
|
|||||||
setIsContinuing(true);
|
setIsContinuing(true);
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
|
|
||||||
await generateChapterContentStream(editingId, (content) => {
|
const result = await generateChapterContentStream(editingId, (content) => {
|
||||||
editorForm.setFieldsValue({ content });
|
editorForm.setFieldsValue({ content });
|
||||||
|
|
||||||
if (contentTextAreaRef.current) {
|
if (contentTextAreaRef.current) {
|
||||||
@@ -170,7 +262,24 @@ export default function Chapters() {
|
|||||||
}
|
}
|
||||||
}, selectedStyleId, targetWordCount);
|
}, 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) {
|
} catch (error) {
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError;
|
||||||
message.error('AI创作失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误'));
|
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 (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -372,15 +526,38 @@ export default function Chapters() {
|
|||||||
}}
|
}}
|
||||||
actions={isMobile ? undefined : [
|
actions={isMobile ? undefined : [
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
|
||||||
icon={<EditOutlined />}
|
icon={<EditOutlined />}
|
||||||
onClick={() => handleOpenEditor(item.id)}
|
onClick={() => handleOpenEditor(item.id)}
|
||||||
>
|
>
|
||||||
编辑内容
|
编辑内容
|
||||||
</Button>,
|
</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
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<EditOutlined />}
|
icon={<SettingOutlined />}
|
||||||
onClick={() => handleOpenModal(item.id)}
|
onClick={() => handleOpenModal(item.id)}
|
||||||
>
|
>
|
||||||
修改信息
|
修改信息
|
||||||
@@ -395,6 +572,7 @@ export default function Chapters() {
|
|||||||
<span>第{item.chapter_number}章:{item.title}</span>
|
<span>第{item.chapter_number}章:{item.title}</span>
|
||||||
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
|
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
|
||||||
<Badge count={`${item.word_count || 0}字`} style={{ backgroundColor: '#52c41a' }} />
|
<Badge count={`${item.word_count || 0}字`} style={{ backgroundColor: '#52c41a' }} />
|
||||||
|
{renderAnalysisStatus(item.id)}
|
||||||
{!canGenerateChapter(item) && (
|
{!canGenerateChapter(item) && (
|
||||||
<Tooltip title={getGenerateDisabledReason(item)}>
|
<Tooltip title={getGenerateDisabledReason(item)}>
|
||||||
<Tag icon={<LockOutlined />} color="warning">
|
<Tag icon={<LockOutlined />} color="warning">
|
||||||
@@ -425,6 +603,30 @@ export default function Chapters() {
|
|||||||
size="small"
|
size="small"
|
||||||
title="编辑内容"
|
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
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<SettingOutlined />}
|
icon={<SettingOutlined />}
|
||||||
@@ -654,6 +856,64 @@ export default function Chapters() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ApartmentOutlined,
|
ApartmentOutlined,
|
||||||
BankOutlined,
|
BankOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
FundOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
|
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
|
||||||
@@ -122,6 +123,11 @@ export default function ProjectDetail() {
|
|||||||
icon: <BookOutlined />,
|
icon: <BookOutlined />,
|
||||||
label: <Link to={`/project/${projectId}/chapters`}>章节管理</Link>,
|
label: <Link to={`/project/${projectId}/chapters`}>章节管理</Link>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'chapter-analysis',
|
||||||
|
icon: <FundOutlined />,
|
||||||
|
label: <Link to={`/project/${projectId}/chapter-analysis`}>剧情分析</Link>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'writing-styles',
|
key: 'writing-styles',
|
||||||
icon: <EditOutlined />,
|
icon: <EditOutlined />,
|
||||||
@@ -142,6 +148,7 @@ export default function ProjectDetail() {
|
|||||||
if (path.includes('/organizations')) return 'organizations';
|
if (path.includes('/organizations')) return 'organizations';
|
||||||
if (path.includes('/outline')) return 'outline';
|
if (path.includes('/outline')) return 'outline';
|
||||||
if (path.includes('/characters')) return 'characters';
|
if (path.includes('/characters')) return 'characters';
|
||||||
|
if (path.includes('/chapter-analysis')) return 'chapter-analysis';
|
||||||
if (path.includes('/chapters')) return 'chapters';
|
if (path.includes('/chapters')) return 'chapters';
|
||||||
if (path.includes('/writing-styles')) return 'writing-styles';
|
if (path.includes('/writing-styles')) return 'writing-styles';
|
||||||
// if (path.includes('/polish')) return 'polish';
|
// if (path.includes('/polish')) return 'polish';
|
||||||
@@ -259,7 +266,8 @@ export default function ProjectDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!mobile && (
|
{!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>
|
<Col>
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
@@ -344,7 +352,8 @@ export default function ProjectDetail() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { 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 } from '@ant-design/icons';
|
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 { useStore } from '../store';
|
||||||
import { useProjectSync } from '../store/hooks';
|
import { useProjectSync } from '../store/hooks';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
@@ -14,6 +15,18 @@ export default function ProjectList() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { projects, loading } = useStore();
|
const { projects, loading } = useStore();
|
||||||
const [showApiTip, setShowApiTip] = useState(true);
|
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();
|
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 totalWords = projects.reduce((sum, p) => sum + (p.current_words || 0), 0);
|
||||||
const activeProjects = projects.filter(p => p.status === 'writing').length;
|
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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
@@ -153,46 +320,165 @@ export default function ProjectList() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={14} style={{ display: 'flex', justifyContent: window.innerWidth <= 768 ? 'space-between' : 'flex-end', alignItems: 'center', gap: 16 }}>
|
<Col xs={24} sm={12} md={14}>
|
||||||
<Button
|
{window.innerWidth <= 768 ? (
|
||||||
type="primary"
|
// 移动端:按钮分两行显示
|
||||||
size={window.innerWidth <= 768 ? 'middle' : 'large'}
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
icon={<RocketOutlined />}
|
<Space size={8} style={{ width: '100%' }}>
|
||||||
onClick={() => navigate('/wizard')}
|
<Button
|
||||||
style={{
|
type="primary"
|
||||||
borderRadius: 8,
|
size="middle"
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
icon={<RocketOutlined />}
|
||||||
border: 'none',
|
onClick={() => navigate('/wizard')}
|
||||||
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.4)'
|
style={{
|
||||||
}}
|
flex: 1,
|
||||||
>
|
borderRadius: 8,
|
||||||
向导创建
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
</Button>
|
border: 'none',
|
||||||
<Button
|
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.4)'
|
||||||
type="default"
|
}}
|
||||||
size={window.innerWidth <= 768 ? 'middle' : 'large'}
|
>
|
||||||
icon={<SettingOutlined />}
|
向导创建
|
||||||
onClick={() => navigate('/settings')}
|
</Button>
|
||||||
style={{
|
<Button
|
||||||
borderRadius: 8,
|
type="default"
|
||||||
borderColor: '#d9d9d9',
|
size="middle"
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
icon={<SettingOutlined />}
|
||||||
transition: 'all 0.3s ease'
|
onClick={() => navigate('/settings')}
|
||||||
}}
|
style={{
|
||||||
onMouseEnter={(e) => {
|
flex: 1,
|
||||||
e.currentTarget.style.borderColor = '#667eea';
|
borderRadius: 8,
|
||||||
e.currentTarget.style.color = '#667eea';
|
borderColor: '#d9d9d9',
|
||||||
e.currentTarget.style.boxShadow = '0 2px 12px rgba(102, 126, 234, 0.3)';
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)'
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
>
|
||||||
e.currentTarget.style.borderColor = '#d9d9d9';
|
API设置
|
||||||
e.currentTarget.style.color = 'rgba(0, 0, 0, 0.88)';
|
</Button>
|
||||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.08)';
|
<UserMenu />
|
||||||
}}
|
</Space>
|
||||||
>
|
<Space size={8} style={{ width: '100%' }}>
|
||||||
API设置
|
<Button
|
||||||
</Button>
|
type="default"
|
||||||
<UserMenu />
|
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>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
@@ -472,6 +758,285 @@ export default function ProjectList() {
|
|||||||
)}
|
)}
|
||||||
</Spin>
|
</Spin>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ export default function SettingsPage() {
|
|||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
api_provider: 'openai',
|
api_provider: 'openai',
|
||||||
api_base_url: 'https://api.openai.com/v1',
|
api_base_url: 'https://api.openai.com/v1',
|
||||||
model_name: 'gpt-4',
|
llm_model: 'gpt-4',
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
max_tokens: 2000,
|
max_tokens: 2000,
|
||||||
});
|
});
|
||||||
@@ -96,7 +96,7 @@ export default function SettingsPage() {
|
|||||||
api_provider: 'openai',
|
api_provider: 'openai',
|
||||||
api_key: '',
|
api_key: '',
|
||||||
api_base_url: 'https://api.openai.com/v1',
|
api_base_url: 'https://api.openai.com/v1',
|
||||||
model_name: 'gpt-4',
|
llm_model: 'gpt-4',
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
max_tokens: 2000,
|
max_tokens: 2000,
|
||||||
});
|
});
|
||||||
@@ -193,7 +193,7 @@ export default function SettingsPage() {
|
|||||||
const apiKey = form.getFieldValue('api_key');
|
const apiKey = form.getFieldValue('api_key');
|
||||||
const apiBaseUrl = form.getFieldValue('api_base_url');
|
const apiBaseUrl = form.getFieldValue('api_base_url');
|
||||||
const provider = form.getFieldValue('api_provider');
|
const provider = form.getFieldValue('api_provider');
|
||||||
const modelName = form.getFieldValue('model_name');
|
const modelName = form.getFieldValue('llm_model');
|
||||||
|
|
||||||
if (!apiKey || !apiBaseUrl || !provider || !modelName) {
|
if (!apiKey || !apiBaseUrl || !provider || !modelName) {
|
||||||
message.warning('请先填写完整的配置信息');
|
message.warning('请先填写完整的配置信息');
|
||||||
@@ -208,7 +208,7 @@ export default function SettingsPage() {
|
|||||||
api_key: apiKey,
|
api_key: apiKey,
|
||||||
api_base_url: apiBaseUrl,
|
api_base_url: apiBaseUrl,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
model_name: modelName
|
llm_model: modelName
|
||||||
});
|
});
|
||||||
|
|
||||||
setTestResult(result);
|
setTestResult(result);
|
||||||
@@ -406,7 +406,7 @@ export default function SettingsPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
name="model_name"
|
name="llm_model"
|
||||||
rules={[{ required: true, message: '请输入或选择模型名称' }]}
|
rules={[{ required: true, message: '请输入或选择模型名称' }]}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export const settingsApi = {
|
|||||||
getAvailableModels: (params: { api_key: string; api_base_url: string; provider: string }) =>
|
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 }),
|
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, {
|
api.post<unknown, {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -177,6 +177,71 @@ export const projectApi = {
|
|||||||
exportProject: (id: string) => {
|
exportProject: (id: string) => {
|
||||||
window.open(`/api/projects/${id}/export`, '_blank');
|
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 = {
|
export const outlineApi = {
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ export function useChapterSync() {
|
|||||||
|
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
let fullContent = '';
|
let fullContent = '';
|
||||||
|
let analysisTaskId: string | undefined;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
@@ -363,9 +364,13 @@ export function useChapterSync() {
|
|||||||
} else if (message.type === 'error') {
|
} else if (message.type === 'error') {
|
||||||
throw new Error(message.error || '生成失败');
|
throw new Error(message.error || '生成失败');
|
||||||
} else if (message.type === 'done') {
|
} else if (message.type === 'done') {
|
||||||
|
// 生成完成,保存分析任务ID
|
||||||
|
analysisTaskId = message.analysis_task_id;
|
||||||
// 生成完成,刷新章节数据
|
// 生成完成,刷新章节数据
|
||||||
await refreshChapters();
|
await refreshChapters();
|
||||||
return { content: fullContent, word_count: message.word_count };
|
} else if (message.type === 'analysis_queued') {
|
||||||
|
// 分析任务已加入队列
|
||||||
|
analysisTaskId = message.task_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -374,7 +379,10 @@ export function useChapterSync() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { content: fullContent };
|
return {
|
||||||
|
content: fullContent,
|
||||||
|
analysis_task_id: analysisTaskId
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('AI流式生成章节内容失败:', error);
|
console.error('AI流式生成章节内容失败:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
+138
-2
@@ -18,7 +18,7 @@ export interface Settings {
|
|||||||
api_provider: string;
|
api_provider: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
api_base_url: string;
|
api_base_url: string;
|
||||||
model_name: string;
|
llm_model: string;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
max_tokens: number;
|
max_tokens: number;
|
||||||
preferences?: string;
|
preferences?: string;
|
||||||
@@ -30,7 +30,7 @@ export interface SettingsUpdate {
|
|||||||
api_provider?: string;
|
api_provider?: string;
|
||||||
api_key?: string;
|
api_key?: string;
|
||||||
api_base_url?: string;
|
api_base_url?: string;
|
||||||
model_name?: string;
|
llm_model?: string;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
max_tokens?: number;
|
max_tokens?: number;
|
||||||
preferences?: string;
|
preferences?: string;
|
||||||
@@ -376,4 +376,140 @@ export interface ApiError {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
message?: string;
|
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