feature:新增拆书导入续写功能,给当年的ta一个结局。
This commit is contained in:
@@ -0,0 +1,262 @@
|
|||||||
|
"""拆书导入 API"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.logger import get_logger
|
||||||
|
from app.schemas.book_import import (
|
||||||
|
BookImportApplyRequest,
|
||||||
|
BookImportApplyResponse,
|
||||||
|
BookImportPreviewResponse,
|
||||||
|
BookImportRetryRequest,
|
||||||
|
BookImportTaskCreateResponse,
|
||||||
|
BookImportTaskStatusResponse,
|
||||||
|
)
|
||||||
|
from app.services.book_import_service import book_import_service
|
||||||
|
from app.utils.sse_response import SSEResponse, create_sse_response
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/book-import", tags=["拆书导入"])
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
MAX_TXT_SIZE = 50 * 1024 * 1024 # 50MB
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks", response_model=BookImportTaskCreateResponse, summary="创建拆书任务(上传TXT)")
|
||||||
|
async def create_book_import_task(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(..., description="TXT 文件"),
|
||||||
|
project_id: str | None = Form(default=None, description="兼容参数:当前版本固定新建项目,不支持传入"),
|
||||||
|
create_new_project: bool = Form(default=True, description="兼容参数:当前版本仅支持 true"),
|
||||||
|
import_mode: str = Form(default="append", description="导入模式:append/overwrite"),
|
||||||
|
):
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="未登录")
|
||||||
|
|
||||||
|
if not file.filename or not file.filename.lower().endswith(".txt"):
|
||||||
|
raise HTTPException(status_code=400, detail="仅支持 .txt 文件")
|
||||||
|
|
||||||
|
if import_mode not in {"append", "overwrite"}:
|
||||||
|
raise HTTPException(status_code=400, detail="import_mode 仅支持 append 或 overwrite")
|
||||||
|
|
||||||
|
if project_id:
|
||||||
|
raise HTTPException(status_code=400, detail="当前仅支持新建项目导入,不支持指定 project_id")
|
||||||
|
if not create_new_project:
|
||||||
|
raise HTTPException(status_code=400, detail="当前仅支持新建项目导入")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
if len(content) > MAX_TXT_SIZE:
|
||||||
|
raise HTTPException(status_code=413, detail="文件大小超过 50MB 限制")
|
||||||
|
|
||||||
|
task = await book_import_service.create_task(
|
||||||
|
user_id=user_id,
|
||||||
|
filename=file.filename,
|
||||||
|
file_content=content,
|
||||||
|
project_id=None,
|
||||||
|
create_new_project=True,
|
||||||
|
import_mode=import_mode,
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}", response_model=BookImportTaskStatusResponse, summary="查询拆书任务状态")
|
||||||
|
async def get_book_import_task_status(task_id: str, request: Request):
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="未登录")
|
||||||
|
|
||||||
|
return await book_import_service.get_task_status(task_id=task_id, user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/preview", response_model=BookImportPreviewResponse, summary="获取拆书预览")
|
||||||
|
async def get_book_import_preview(task_id: str, request: Request):
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="未登录")
|
||||||
|
|
||||||
|
return await book_import_service.get_preview(task_id=task_id, user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/apply", response_model=BookImportApplyResponse, summary="确认并导入")
|
||||||
|
async def apply_book_import(
|
||||||
|
task_id: str,
|
||||||
|
payload: BookImportApplyRequest,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="未登录")
|
||||||
|
|
||||||
|
return await book_import_service.apply_import(
|
||||||
|
task_id=task_id,
|
||||||
|
user_id=user_id,
|
||||||
|
payload=payload,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tasks/{task_id}", summary="取消拆书任务")
|
||||||
|
async def cancel_book_import_task(task_id: str, request: Request):
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="未登录")
|
||||||
|
|
||||||
|
return await book_import_service.cancel_task(task_id=task_id, user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/apply-stream", summary="确认并导入(SSE流式进度)")
|
||||||
|
async def apply_book_import_stream(
|
||||||
|
task_id: str,
|
||||||
|
payload: BookImportApplyRequest,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
SSE 流式接口:执行基础导入后,分步生成世界观/职业/角色并实时推送进度。
|
||||||
|
使用 asyncio.Queue 在服务与 SSE 生成器之间传递进度消息。
|
||||||
|
"""
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="未登录")
|
||||||
|
|
||||||
|
# 使用 asyncio.Queue 实现实时进度推送
|
||||||
|
progress_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||||
|
|
||||||
|
async def _progress_callback(message: str, progress: int, status: str = "processing") -> None:
|
||||||
|
"""进度回调:放入队列供 SSE 生成器消费"""
|
||||||
|
sse_msg = SSEResponse.format_sse({
|
||||||
|
"type": "progress",
|
||||||
|
"message": message,
|
||||||
|
"progress": progress,
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
await progress_queue.put(sse_msg)
|
||||||
|
|
||||||
|
async def _run_import() -> None:
|
||||||
|
"""在后台任务中执行导入并通过队列推送进度"""
|
||||||
|
try:
|
||||||
|
result = await book_import_service.apply_import_stream(
|
||||||
|
task_id=task_id,
|
||||||
|
user_id=user_id,
|
||||||
|
payload=payload,
|
||||||
|
db=db,
|
||||||
|
progress_callback=_progress_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送结果
|
||||||
|
await progress_queue.put(await SSEResponse.send_result({
|
||||||
|
"success": result.success,
|
||||||
|
"project_id": result.project_id,
|
||||||
|
"statistics": result.statistics,
|
||||||
|
}))
|
||||||
|
await progress_queue.put(await SSEResponse.send_progress("导入完成!", 100, "success"))
|
||||||
|
await progress_queue.put(await SSEResponse.send_done())
|
||||||
|
except HTTPException as exc:
|
||||||
|
await progress_queue.put(await SSEResponse.send_error(exc.detail, exc.status_code))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"拆书SSE导入失败: {exc}", exc_info=True)
|
||||||
|
await progress_queue.put(await SSEResponse.send_error(str(exc), 500))
|
||||||
|
finally:
|
||||||
|
# 发送终止信号
|
||||||
|
await progress_queue.put(None)
|
||||||
|
|
||||||
|
async def _streaming_generator() -> AsyncGenerator[str, None]:
|
||||||
|
yield await SSEResponse.send_progress("开始导入拆书数据...", 0, "processing")
|
||||||
|
|
||||||
|
# 启动后台导入任务
|
||||||
|
import_task = asyncio.create_task(_run_import())
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await progress_queue.get()
|
||||||
|
if msg is None:
|
||||||
|
break
|
||||||
|
yield msg
|
||||||
|
except GeneratorExit:
|
||||||
|
import_task.cancel()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"SSE生成器异常: {exc}", exc_info=True)
|
||||||
|
yield await SSEResponse.send_error(str(exc), 500)
|
||||||
|
|
||||||
|
return create_sse_response(_streaming_generator())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/retry-stream", summary="重试失败的生成步骤(SSE流式进度)")
|
||||||
|
async def retry_failed_steps_stream(
|
||||||
|
task_id: str,
|
||||||
|
payload: BookImportRetryRequest,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
SSE 流式接口:仅重试之前导入过程中失败的AI生成步骤(世界观/职业/角色)。
|
||||||
|
"""
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="未登录")
|
||||||
|
|
||||||
|
progress_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||||
|
|
||||||
|
async def _progress_callback(message: str, progress: int, status: str = "processing") -> None:
|
||||||
|
sse_msg = SSEResponse.format_sse({
|
||||||
|
"type": "progress",
|
||||||
|
"message": message,
|
||||||
|
"progress": progress,
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
await progress_queue.put(sse_msg)
|
||||||
|
|
||||||
|
async def _run_retry() -> None:
|
||||||
|
try:
|
||||||
|
result = await book_import_service.retry_failed_steps_stream(
|
||||||
|
task_id=task_id,
|
||||||
|
user_id=user_id,
|
||||||
|
steps_to_retry=payload.steps,
|
||||||
|
db=db,
|
||||||
|
progress_callback=_progress_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
await progress_queue.put(await SSEResponse.send_result(result))
|
||||||
|
|
||||||
|
if result.get("still_failed"):
|
||||||
|
await progress_queue.put(await SSEResponse.send_progress(
|
||||||
|
f"重试完成,仍有 {len(result['still_failed'])} 个步骤失败",
|
||||||
|
100,
|
||||||
|
"warning",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
await progress_queue.put(await SSEResponse.send_progress("所有步骤重试成功!", 100, "success"))
|
||||||
|
|
||||||
|
await progress_queue.put(await SSEResponse.send_done())
|
||||||
|
except HTTPException as exc:
|
||||||
|
await progress_queue.put(await SSEResponse.send_error(exc.detail, exc.status_code))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"拆书SSE重试失败: {exc}", exc_info=True)
|
||||||
|
await progress_queue.put(await SSEResponse.send_error(str(exc), 500))
|
||||||
|
finally:
|
||||||
|
await progress_queue.put(None)
|
||||||
|
|
||||||
|
async def _streaming_generator() -> AsyncGenerator[str, None]:
|
||||||
|
yield await SSEResponse.send_progress("开始重试失败的生成步骤...", 0, "processing")
|
||||||
|
|
||||||
|
retry_task = asyncio.create_task(_run_retry())
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await progress_queue.get()
|
||||||
|
if msg is None:
|
||||||
|
break
|
||||||
|
yield msg
|
||||||
|
except GeneratorExit:
|
||||||
|
retry_task.cancel()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"SSE重试生成器异常: {exc}", exc_info=True)
|
||||||
|
yield await SSEResponse.send_error(str(exc), 500)
|
||||||
|
|
||||||
|
return create_sse_response(_streaming_generator())
|
||||||
+2
-1
@@ -130,7 +130,7 @@ from app.api import (
|
|||||||
wizard_stream, relationships, organizations,
|
wizard_stream, relationships, organizations,
|
||||||
auth, users, settings, writing_styles, memories,
|
auth, users, settings, writing_styles, memories,
|
||||||
mcp_plugins, admin, inspiration, prompt_templates,
|
mcp_plugins, admin, inspiration, prompt_templates,
|
||||||
changelog, careers, foreshadows, prompt_workshop
|
changelog, careers, foreshadows, prompt_workshop, book_import
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth.router, prefix="/api")
|
app.include_router(auth.router, prefix="/api")
|
||||||
@@ -154,6 +154,7 @@ app.include_router(mcp_plugins.router, prefix="/api") # MCP插件管理API
|
|||||||
app.include_router(prompt_templates.router, prefix="/api") # 提示词模板管理API
|
app.include_router(prompt_templates.router, prefix="/api") # 提示词模板管理API
|
||||||
app.include_router(changelog.router, prefix="/api") # 更新日志API
|
app.include_router(changelog.router, prefix="/api") # 更新日志API
|
||||||
app.include_router(prompt_workshop.router, prefix="/api") # 提示词工坊API
|
app.include_router(prompt_workshop.router, prefix="/api") # 提示词工坊API
|
||||||
|
app.include_router(book_import.router, prefix="/api") # 拆书导入API
|
||||||
|
|
||||||
static_dir = Path(__file__).parent.parent / "static"
|
static_dir = Path(__file__).parent.parent / "static"
|
||||||
if static_dir.exists():
|
if static_dir.exists():
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""拆书导入相关的 Pydantic Schema"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
TaskStatus = Literal["pending", "running", "completed", "failed", "cancelled"]
|
||||||
|
ImportMode = Literal["append", "overwrite"]
|
||||||
|
ExtractLevel = Literal["basic", "standard", "deep"]
|
||||||
|
WarningLevel = Literal["info", "warning", "error"]
|
||||||
|
|
||||||
|
|
||||||
|
class BookImportWarning(BaseModel):
|
||||||
|
"""导入告警信息"""
|
||||||
|
code: str = Field(..., description="告警编码")
|
||||||
|
message: str = Field(..., description="告警内容")
|
||||||
|
level: WarningLevel = Field(default="warning", description="告警等级")
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectSuggestion(BaseModel):
|
||||||
|
"""项目建议信息(可在预览页修改)"""
|
||||||
|
title: str = Field(..., min_length=1, max_length=200, description="项目标题")
|
||||||
|
description: Optional[str] = Field(None, description="项目简介")
|
||||||
|
theme: Optional[str] = Field(None, description="主题")
|
||||||
|
genre: Optional[str] = Field(None, description="类型")
|
||||||
|
narrative_perspective: str = Field(default="第三人称", description="叙事视角")
|
||||||
|
target_words: int = Field(default=100000, ge=1000, description="目标字数(默认10万字)")
|
||||||
|
|
||||||
|
|
||||||
|
class BookImportChapter(BaseModel):
|
||||||
|
"""预览章节"""
|
||||||
|
title: str = Field(..., min_length=1, max_length=200, description="章节标题")
|
||||||
|
content: str = Field(default="", description="章节正文")
|
||||||
|
summary: Optional[str] = Field(None, description="章节摘要")
|
||||||
|
chapter_number: int = Field(..., ge=1, description="章节序号")
|
||||||
|
outline_title: Optional[str] = Field(None, description="关联大纲标题(可选)")
|
||||||
|
|
||||||
|
|
||||||
|
class BookImportOutline(BaseModel):
|
||||||
|
"""预览大纲"""
|
||||||
|
title: str = Field(..., min_length=1, max_length=200, description="大纲标题")
|
||||||
|
content: Optional[str] = Field(None, description="大纲内容")
|
||||||
|
order_index: int = Field(..., ge=1, description="排序序号")
|
||||||
|
structure: Optional[dict[str, Any]] = Field(None, description="结构化大纲(与系统大纲生成结构一致)")
|
||||||
|
|
||||||
|
|
||||||
|
class BookImportTaskCreateResponse(BaseModel):
|
||||||
|
"""创建任务响应"""
|
||||||
|
task_id: str
|
||||||
|
status: TaskStatus
|
||||||
|
|
||||||
|
|
||||||
|
class BookImportTaskStatusResponse(BaseModel):
|
||||||
|
"""任务状态响应"""
|
||||||
|
task_id: str
|
||||||
|
status: TaskStatus
|
||||||
|
progress: int = Field(..., ge=0, le=100)
|
||||||
|
message: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class BookImportPreviewResponse(BaseModel):
|
||||||
|
"""预览数据响应"""
|
||||||
|
task_id: str
|
||||||
|
project_suggestion: ProjectSuggestion
|
||||||
|
chapters: list[BookImportChapter]
|
||||||
|
outlines: list[BookImportOutline]
|
||||||
|
warnings: list[BookImportWarning]
|
||||||
|
|
||||||
|
|
||||||
|
class BookImportApplyRequest(BaseModel):
|
||||||
|
"""确认导入请求(支持前端修订后的数据)"""
|
||||||
|
project_suggestion: ProjectSuggestion
|
||||||
|
chapters: list[BookImportChapter]
|
||||||
|
outlines: list[BookImportOutline] = Field(default_factory=list)
|
||||||
|
import_mode: ImportMode = Field(default="append", description="导入模式")
|
||||||
|
|
||||||
|
|
||||||
|
class BookImportApplyResponse(BaseModel):
|
||||||
|
"""确认导入响应"""
|
||||||
|
success: bool
|
||||||
|
project_id: str
|
||||||
|
statistics: dict[str, int]
|
||||||
|
warnings: list[BookImportWarning] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class BookImportRetryRequest(BaseModel):
|
||||||
|
"""重试失败步骤请求"""
|
||||||
|
steps: list[str] = Field(..., min_length=1, description="需要重试的步骤名列表,如 world_building / career_system / characters")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2416,6 +2416,136 @@ class PromptService:
|
|||||||
❌ 添加任何元信息或说明
|
❌ 添加任何元信息或说明
|
||||||
❌ 改变叙事人称或视角
|
❌ 改变叙事人称或视角
|
||||||
❌ 偏离用户的修改要求
|
❌ 偏离用户的修改要求
|
||||||
|
</constraints>"""
|
||||||
|
|
||||||
|
# 拆书导入-反向项目提炼提示词
|
||||||
|
BOOK_IMPORT_REVERSE_PROJECT_SUGGESTION = """<system>
|
||||||
|
你是资深网文策划编辑,擅长从小说正文中反向提炼项目立项信息。
|
||||||
|
</system>
|
||||||
|
|
||||||
|
<task>
|
||||||
|
【任务】
|
||||||
|
基于提供的前3章内容,提炼该小说的核心立项信息,用于创建新项目。
|
||||||
|
|
||||||
|
【目标】
|
||||||
|
在不偏离原文的前提下,输出可直接用于项目初始化的结构化信息。
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<input priority="P0">
|
||||||
|
【输入信息】
|
||||||
|
书名:{title}
|
||||||
|
前3章内容:
|
||||||
|
{sampled_text}
|
||||||
|
</input>
|
||||||
|
|
||||||
|
<output priority="P0">
|
||||||
|
【输出格式】
|
||||||
|
仅输出一个纯JSON对象(不要markdown、不要代码块、不要解释):
|
||||||
|
|
||||||
|
{{
|
||||||
|
"description": "小说简介",
|
||||||
|
"theme": "核心主题",
|
||||||
|
"genre": "小说类型",
|
||||||
|
"narrative_perspective": "第一人称/第三人称/全知视角",
|
||||||
|
"target_words": 100000
|
||||||
|
}}
|
||||||
|
|
||||||
|
【字段要求】
|
||||||
|
1) description:120-260字,聚焦主角、核心冲突、主线目标与故事张力。
|
||||||
|
2) theme:120-260字,提炼作品想表达的核心命题。
|
||||||
|
3) genre:2-12字,如都市、玄幻、悬疑、科幻、言情等。
|
||||||
|
4) narrative_perspective:只能是“第一人称”或“第三人称”或“全知视角”。
|
||||||
|
5) target_words:整数。按网文体量合理预估;无法判断时返回100000。
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<constraints>
|
||||||
|
【必须遵守】
|
||||||
|
✅ 严格基于已给正文内容,不凭空添加关键设定
|
||||||
|
✅ 保持信息自洽,避免互相矛盾
|
||||||
|
✅ 输出必须是可解析JSON对象
|
||||||
|
✅ 小说的genre可以由多个类型组成
|
||||||
|
|
||||||
|
【禁止事项】
|
||||||
|
❌ 输出JSON以外的任何文字
|
||||||
|
❌ 使用markdown标记或代码块包裹
|
||||||
|
❌ narrative_perspective输出枚举值之外的内容
|
||||||
|
❌ target_words输出非整数
|
||||||
|
</constraints>"""
|
||||||
|
|
||||||
|
# 拆书导入-反向生成章节大纲(严格对齐 OUTLINE_CREATE 结构)
|
||||||
|
BOOK_IMPORT_REVERSE_OUTLINES = """<system>
|
||||||
|
你是资深网文总编与剧情策划,擅长基于已完成章节反向提炼标准化章节大纲。
|
||||||
|
</system>
|
||||||
|
|
||||||
|
<task>
|
||||||
|
【任务】
|
||||||
|
基于给定的章节正文(每批最多5章),为每章反向生成对应大纲结构。
|
||||||
|
|
||||||
|
【核心目标】
|
||||||
|
输出结构必须与系统现有大纲生成结构严格一致(与 OUTLINE_CREATE 字段一致),用于直接入库。
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<project priority="P0">
|
||||||
|
【项目信息】
|
||||||
|
书名:{title}
|
||||||
|
类型:{genre}
|
||||||
|
主题:{theme}
|
||||||
|
叙事视角:{narrative_perspective}
|
||||||
|
</project>
|
||||||
|
|
||||||
|
<input priority="P0">
|
||||||
|
【批次范围】
|
||||||
|
第{start_chapter}章 - 第{end_chapter}章(共{expected_count}章)
|
||||||
|
|
||||||
|
【章节内容】
|
||||||
|
{chapters_text}
|
||||||
|
</input>
|
||||||
|
|
||||||
|
<output priority="P0">
|
||||||
|
【输出格式】
|
||||||
|
仅输出纯JSON数组(不要markdown、不要代码块、不要解释)。
|
||||||
|
数组长度必须严格等于 {expected_count}。
|
||||||
|
|
||||||
|
每个对象字段必须严格为:
|
||||||
|
[
|
||||||
|
{{
|
||||||
|
"chapter_number": 1,
|
||||||
|
"title": "章节标题",
|
||||||
|
"summary": "章节概要(200-600字):主要情节、角色互动、关键事件、冲突与转折",
|
||||||
|
"scenes": ["场景1描述", "场景2描述"],
|
||||||
|
"characters": [
|
||||||
|
{{"name": "角色名1", "type": "character"}},
|
||||||
|
{{"name": "组织/势力名1", "type": "organization"}}
|
||||||
|
],
|
||||||
|
"key_points": ["情节要点1", "情节要点2"],
|
||||||
|
"emotion": "本章情感基调",
|
||||||
|
"goal": "本章叙事目标"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
|
||||||
|
【字段约束】
|
||||||
|
- chapter_number:必须与输入章节号一致
|
||||||
|
- title:必须与输入章节标题一致
|
||||||
|
- summary:根据本章正文反向提炼,不得臆造未出现关键事件
|
||||||
|
- scenes:2-6条
|
||||||
|
- characters:可为空;type 仅允许 character 或 organization
|
||||||
|
- key_points:2-6条
|
||||||
|
- emotion:一句话
|
||||||
|
- goal:一句话
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<constraints>
|
||||||
|
【必须遵守】
|
||||||
|
✅ 严格一章对应一个对象,数量与顺序完全一致
|
||||||
|
✅ 字段名、字段层级、字段类型严格一致
|
||||||
|
✅ 仅基于输入正文提炼,不擅自扩展设定
|
||||||
|
✅ 输出必须可被JSON直接解析
|
||||||
|
|
||||||
|
【禁止事项】
|
||||||
|
❌ 输出JSON之外任何文本
|
||||||
|
❌ 缺失字段或新增字段
|
||||||
|
❌ chapter_number/title 与输入不一致
|
||||||
|
❌ 使用 markdown 或代码块
|
||||||
</constraints>"""
|
</constraints>"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -2689,6 +2819,21 @@ class PromptService:
|
|||||||
"description": "用于生成小说世界观设定,包括时间背景、地理位置、氛围基调和世界规则",
|
"description": "用于生成小说世界观设定,包括时间背景、地理位置、氛围基调和世界规则",
|
||||||
"parameters": ["title", "theme", "genre", "description"]
|
"parameters": ["title", "theme", "genre", "description"]
|
||||||
},
|
},
|
||||||
|
"BOOK_IMPORT_REVERSE_PROJECT_SUGGESTION": {
|
||||||
|
"name": "拆书导入-反向项目提炼",
|
||||||
|
"category": "拆书导入",
|
||||||
|
"description": "基于前3章内容反向提炼简介、主题、类型、叙事视角与目标字数",
|
||||||
|
"parameters": ["title", "sampled_text"]
|
||||||
|
},
|
||||||
|
"BOOK_IMPORT_REVERSE_OUTLINES": {
|
||||||
|
"name": "拆书导入-反向章节大纲",
|
||||||
|
"category": "拆书导入",
|
||||||
|
"description": "基于章节正文反向生成与OUTLINE_CREATE一致结构的大纲(单批次5章)",
|
||||||
|
"parameters": [
|
||||||
|
"title", "genre", "theme", "narrative_perspective",
|
||||||
|
"start_chapter", "end_chapter", "expected_count", "chapters_text"
|
||||||
|
]
|
||||||
|
},
|
||||||
"CHARACTERS_BATCH_GENERATION": {
|
"CHARACTERS_BATCH_GENERATION": {
|
||||||
"name": "批量角色生成",
|
"name": "批量角色生成",
|
||||||
"category": "角色生成",
|
"category": "角色生成",
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""TXT 解析服务:编码识别、文本清洗与章节切分"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TxtParserService:
|
||||||
|
"""TXT 解析服务(规则优先)"""
|
||||||
|
|
||||||
|
STRONG_CHAPTER_PATTERNS = [
|
||||||
|
re.compile(r"^第[一二三四五六七八九十百千万零〇两\d]+[章节回卷集部篇].*$"),
|
||||||
|
re.compile(r"^chapter\s*\d+.*$", re.IGNORECASE),
|
||||||
|
re.compile(r"^chap\.\s*\d+.*$", re.IGNORECASE),
|
||||||
|
]
|
||||||
|
|
||||||
|
def decode_bytes(self, content: bytes) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
尝试解码 TXT 字节流
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(text, encoding)
|
||||||
|
"""
|
||||||
|
encodings = ["utf-8", "utf-8-sig", "gb18030", "gbk", "big5"]
|
||||||
|
for enc in encodings:
|
||||||
|
try:
|
||||||
|
return content.decode(enc), enc
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 最后兜底:不抛错,尽量读出内容
|
||||||
|
logger.warning("TXT 编码自动识别失败,使用 utf-8(ignore) 兜底")
|
||||||
|
return content.decode("utf-8", errors="ignore"), "utf-8(ignore)"
|
||||||
|
|
||||||
|
def clean_text(self, text: str) -> str:
|
||||||
|
"""基础清洗:换行归一、去除异常空白、压缩多余空行"""
|
||||||
|
normalized = text.replace("\r\n", "\n").replace("\r", "\n").replace("\ufeff", "")
|
||||||
|
normalized = normalized.replace("\u3000", " ")
|
||||||
|
normalized = re.sub(r"[ \t]+\n", "\n", normalized)
|
||||||
|
normalized = re.sub(r"\n{4,}", "\n\n\n", normalized)
|
||||||
|
return normalized.strip()
|
||||||
|
|
||||||
|
def split_chapters(self, text: str) -> list[dict]:
|
||||||
|
"""
|
||||||
|
章节切分(规则优先,失败兜底)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[{title, content, chapter_number}]
|
||||||
|
"""
|
||||||
|
if not text.strip():
|
||||||
|
return []
|
||||||
|
|
||||||
|
lines = text.split("\n")
|
||||||
|
heading_indexes: list[int] = []
|
||||||
|
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
if self._is_strong_heading(stripped) or self._is_weak_heading(lines, idx):
|
||||||
|
heading_indexes.append(idx)
|
||||||
|
|
||||||
|
# 去重并排序
|
||||||
|
heading_indexes = sorted(set(heading_indexes))
|
||||||
|
|
||||||
|
# 如果一个标题都识别不到,走固定窗口兜底
|
||||||
|
if not heading_indexes:
|
||||||
|
return self._fallback_split(text)
|
||||||
|
|
||||||
|
# 如果第一个标题前有较长正文,作为前言章节保留
|
||||||
|
chapters: list[dict] = []
|
||||||
|
chapter_no = 1
|
||||||
|
|
||||||
|
first_heading = heading_indexes[0]
|
||||||
|
if first_heading > 0:
|
||||||
|
preface = "\n".join(lines[:first_heading]).strip()
|
||||||
|
if len(preface) >= 200:
|
||||||
|
chapters.append(
|
||||||
|
{
|
||||||
|
"title": "前言",
|
||||||
|
"content": preface,
|
||||||
|
"chapter_number": chapter_no,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
chapter_no += 1
|
||||||
|
|
||||||
|
for i, start_idx in enumerate(heading_indexes):
|
||||||
|
end_idx = heading_indexes[i + 1] if i + 1 < len(heading_indexes) else len(lines)
|
||||||
|
title = lines[start_idx].strip()[:200] or f"第{chapter_no}章"
|
||||||
|
body = "\n".join(lines[start_idx + 1 : end_idx]).strip()
|
||||||
|
# 防止空标题/空正文完全丢失
|
||||||
|
if not body and i + 1 < len(heading_indexes):
|
||||||
|
next_line = lines[start_idx + 1].strip() if start_idx + 1 < len(lines) else ""
|
||||||
|
body = next_line
|
||||||
|
|
||||||
|
chapters.append(
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"content": body,
|
||||||
|
"chapter_number": chapter_no,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
chapter_no += 1
|
||||||
|
|
||||||
|
# 过滤掉明显噪音章节
|
||||||
|
filtered = [c for c in chapters if c["title"] or c["content"]]
|
||||||
|
if filtered:
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
return self._fallback_split(text)
|
||||||
|
|
||||||
|
def _is_strong_heading(self, line: str) -> bool:
|
||||||
|
return any(pattern.match(line) for pattern in self.STRONG_CHAPTER_PATTERNS)
|
||||||
|
|
||||||
|
def _is_weak_heading(self, lines: list[str], idx: int) -> bool:
|
||||||
|
"""
|
||||||
|
弱模式:短行 + 前后空行 + 避免普通句子误判
|
||||||
|
"""
|
||||||
|
line = lines[idx].strip()
|
||||||
|
if not line:
|
||||||
|
return False
|
||||||
|
if len(line) > 25:
|
||||||
|
return False
|
||||||
|
if re.search(r"[,。!?;:,.!?;:]", line):
|
||||||
|
return False
|
||||||
|
|
||||||
|
prev_blank = idx == 0 or not lines[idx - 1].strip()
|
||||||
|
next_blank = idx == len(lines) - 1 or not lines[idx + 1].strip()
|
||||||
|
return prev_blank and next_blank
|
||||||
|
|
||||||
|
def _fallback_split(self, text: str, min_window: int = 3000, max_window: int = 5000) -> list[dict]:
|
||||||
|
"""
|
||||||
|
固定窗口 + 标点边界切分
|
||||||
|
"""
|
||||||
|
chapters: list[dict] = []
|
||||||
|
n = len(text)
|
||||||
|
start = 0
|
||||||
|
chapter_no = 1
|
||||||
|
boundary_punctuations = "。!?!?\n"
|
||||||
|
|
||||||
|
while start < n:
|
||||||
|
ideal_end = min(start + max_window, n)
|
||||||
|
if ideal_end >= n:
|
||||||
|
end = n
|
||||||
|
else:
|
||||||
|
search_from = min(start + min_window, n)
|
||||||
|
segment = text[search_from:ideal_end]
|
||||||
|
offset = max(segment.rfind(p) for p in boundary_punctuations)
|
||||||
|
end = search_from + offset + 1 if offset >= 0 else ideal_end
|
||||||
|
|
||||||
|
chunk = text[start:end].strip()
|
||||||
|
if chunk:
|
||||||
|
chapters.append(
|
||||||
|
{
|
||||||
|
"title": f"第{chapter_no}章",
|
||||||
|
"content": chunk,
|
||||||
|
"chapter_number": chapter_no,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
chapter_no += 1
|
||||||
|
|
||||||
|
start = end
|
||||||
|
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
txt_parser_service = TxtParserService()
|
||||||
@@ -0,0 +1,918 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Collapse,
|
||||||
|
Empty,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
List,
|
||||||
|
message,
|
||||||
|
Progress,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Steps,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Upload,
|
||||||
|
} from 'antd';
|
||||||
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
|
import { InboxOutlined, PlayCircleOutlined, ReloadOutlined, StopOutlined, WarningOutlined, RedoOutlined } from '@ant-design/icons';
|
||||||
|
import { bookImportApi } from '../services/api';
|
||||||
|
import type {
|
||||||
|
BookImportApplyPayload,
|
||||||
|
BookImportPreview,
|
||||||
|
BookImportStepFailure,
|
||||||
|
BookImportTask,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const { Dragger } = Upload;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
const BOOK_IMPORT_CACHE_KEY = 'book_import_page_cache_v1';
|
||||||
|
|
||||||
|
type BookImportPageCache = {
|
||||||
|
taskId: string | null;
|
||||||
|
taskStatus: BookImportTask | null;
|
||||||
|
preview: BookImportPreview | null;
|
||||||
|
applyProgress: number;
|
||||||
|
applyMessage: string;
|
||||||
|
applyError: string | null;
|
||||||
|
isApplyComplete: boolean;
|
||||||
|
cachedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadBookImportCache(): BookImportPageCache | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(BOOK_IMPORT_CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw) as BookImportPageCache;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('读取拆书页面缓存失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBookImportCache(cache: BookImportPageCache) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(BOOK_IMPORT_CACHE_KEY, JSON.stringify(cache));
|
||||||
|
} catch (error) {
|
||||||
|
const isQuotaExceeded =
|
||||||
|
error instanceof DOMException &&
|
||||||
|
(error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED');
|
||||||
|
|
||||||
|
if (isQuotaExceeded) {
|
||||||
|
// 发生容量溢出时降级为轻量缓存(不保存预览正文),避免持续报错
|
||||||
|
try {
|
||||||
|
const lightweightCache: BookImportPageCache = {
|
||||||
|
...cache,
|
||||||
|
preview: null,
|
||||||
|
};
|
||||||
|
sessionStorage.setItem(BOOK_IMPORT_CACHE_KEY, JSON.stringify(lightweightCache));
|
||||||
|
return;
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.warn('写入轻量拆书页面缓存失败:', fallbackError);
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(BOOK_IMPORT_CACHE_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('写入拆书页面缓存失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBookImportCache() {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(BOOK_IMPORT_CACHE_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('清理拆书页面缓存失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotFoundError(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object') return false;
|
||||||
|
const maybeError = error as { response?: { status?: number } };
|
||||||
|
return maybeError.response?.status === 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookImport() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const [taskId, setTaskId] = useState<string | null>(null);
|
||||||
|
const [taskStatus, setTaskStatus] = useState<BookImportTask | null>(null);
|
||||||
|
const [preview, setPreview] = useState<BookImportPreview | null>(null);
|
||||||
|
|
||||||
|
const [creatingTask, setCreatingTask] = useState(false);
|
||||||
|
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [applyProgress, setApplyProgress] = useState(0);
|
||||||
|
const [applyMessage, setApplyMessage] = useState('');
|
||||||
|
const [applyError, setApplyError] = useState<string | null>(null);
|
||||||
|
const [isApplyComplete, setIsApplyComplete] = useState(false);
|
||||||
|
const [cacheReady, setCacheReady] = useState(false);
|
||||||
|
|
||||||
|
// 步骤级失败和重试相关状态
|
||||||
|
const [failedSteps, setFailedSteps] = useState<BookImportStepFailure[]>([]);
|
||||||
|
const [retrying, setRetrying] = useState(false);
|
||||||
|
const [retryProgress, setRetryProgress] = useState(0);
|
||||||
|
const [retryMessage, setRetryMessage] = useState('');
|
||||||
|
const importedProjectId = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const isTaskTerminal = useMemo(() => {
|
||||||
|
return !!taskStatus && ['completed', 'failed', 'cancelled'].includes(taskStatus.status);
|
||||||
|
}, [taskStatus]);
|
||||||
|
|
||||||
|
const currentStep = useMemo(() => {
|
||||||
|
if (!taskId) return 0;
|
||||||
|
if (taskStatus && ['pending', 'running'].includes(taskStatus.status)) return 1;
|
||||||
|
if (applying || isApplyComplete) return 3; // 新增生成导入步骤
|
||||||
|
if (preview) return 2;
|
||||||
|
return 1;
|
||||||
|
}, [taskId, taskStatus, preview, applying, isApplyComplete]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cache = loadBookImportCache();
|
||||||
|
if (cache) {
|
||||||
|
const cacheAgeMs = typeof cache.cachedAt === 'number'
|
||||||
|
? Date.now() - cache.cachedAt
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
// 超过6小时的缓存直接视为失效,避免后端重启后继续使用旧taskId
|
||||||
|
if (cacheAgeMs > 6 * 60 * 60 * 1000) {
|
||||||
|
clearBookImportCache();
|
||||||
|
} else {
|
||||||
|
setTaskId(cache.taskId);
|
||||||
|
setTaskStatus(cache.taskStatus);
|
||||||
|
setPreview(cache.preview);
|
||||||
|
setApplyProgress(cache.applyProgress);
|
||||||
|
setApplyError(cache.applyError);
|
||||||
|
setIsApplyComplete(cache.isApplyComplete);
|
||||||
|
setApplyMessage(
|
||||||
|
cache.applyMessage || (cache.applyProgress > 0 && !cache.isApplyComplete
|
||||||
|
? '已恢复页面缓存,请重新点击“确认导入”继续。'
|
||||||
|
: '')
|
||||||
|
);
|
||||||
|
message.info('已恢复拆书导入页面缓存');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCacheReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cacheReady) return;
|
||||||
|
|
||||||
|
// 导入完成后必须清理缓存,避免后续回到页面时恢复到旧任务状态
|
||||||
|
if (isApplyComplete) {
|
||||||
|
clearBookImportCache();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCacheData = Boolean(
|
||||||
|
taskId ||
|
||||||
|
taskStatus ||
|
||||||
|
preview ||
|
||||||
|
applyError ||
|
||||||
|
applyProgress > 0 ||
|
||||||
|
applyMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasCacheData) {
|
||||||
|
clearBookImportCache();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBookImportCache({
|
||||||
|
taskId,
|
||||||
|
taskStatus,
|
||||||
|
// preview 含完整章节正文,体积大,容易触发 sessionStorage 配额限制
|
||||||
|
// 页面恢复时可根据 taskId + taskStatus 重新拉取 preview
|
||||||
|
preview: null,
|
||||||
|
applyProgress,
|
||||||
|
applyMessage,
|
||||||
|
applyError,
|
||||||
|
isApplyComplete,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
cacheReady,
|
||||||
|
taskId,
|
||||||
|
taskStatus,
|
||||||
|
preview,
|
||||||
|
applyProgress,
|
||||||
|
applyMessage,
|
||||||
|
applyError,
|
||||||
|
isApplyComplete,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskId) return;
|
||||||
|
if (isTaskTerminal) return;
|
||||||
|
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const status = await bookImportApi.getTaskStatus(taskId);
|
||||||
|
setTaskStatus(status);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('轮询任务状态失败:', error);
|
||||||
|
if (isNotFoundError(error)) {
|
||||||
|
clearBookImportCache();
|
||||||
|
setTaskId(null);
|
||||||
|
setTaskStatus(null);
|
||||||
|
setPreview(null);
|
||||||
|
setApplyProgress(0);
|
||||||
|
setApplyMessage('');
|
||||||
|
setApplyError(null);
|
||||||
|
setIsApplyComplete(false);
|
||||||
|
message.warning('拆书任务已失效(可能因服务重启),请重新上传TXT并开始解析');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [taskId, isTaskTerminal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPreview = async () => {
|
||||||
|
if (!taskId || !taskStatus) return;
|
||||||
|
if (taskStatus.status !== 'completed' || preview) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoadingPreview(true);
|
||||||
|
const data = await bookImportApi.getPreview(taskId);
|
||||||
|
setPreview(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取预览失败:', error);
|
||||||
|
if (isNotFoundError(error)) {
|
||||||
|
clearBookImportCache();
|
||||||
|
setTaskId(null);
|
||||||
|
setTaskStatus(null);
|
||||||
|
setPreview(null);
|
||||||
|
setApplyProgress(0);
|
||||||
|
setApplyMessage('');
|
||||||
|
setApplyError(null);
|
||||||
|
setIsApplyComplete(false);
|
||||||
|
message.warning('拆书任务预览不存在(可能因服务重启),已清空缓存,请重新上传TXT');
|
||||||
|
} else {
|
||||||
|
message.error('获取预览失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingPreview(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPreview();
|
||||||
|
}, [taskId, taskStatus, preview]);
|
||||||
|
|
||||||
|
const startTask = async () => {
|
||||||
|
if (!file) {
|
||||||
|
message.warning('请先选择 TXT 文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCreatingTask(true);
|
||||||
|
setPreview(null);
|
||||||
|
setTaskStatus(null);
|
||||||
|
|
||||||
|
const response = await bookImportApi.createTask({
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTaskId(response.task_id);
|
||||||
|
message.success('拆书任务已创建');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建任务失败:', error);
|
||||||
|
message.error('创建拆书任务失败');
|
||||||
|
} finally {
|
||||||
|
setCreatingTask(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshStatus = async () => {
|
||||||
|
if (!taskId) return;
|
||||||
|
try {
|
||||||
|
const status = await bookImportApi.getTaskStatus(taskId);
|
||||||
|
setTaskStatus(status);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新状态失败:', error);
|
||||||
|
if (isNotFoundError(error)) {
|
||||||
|
clearBookImportCache();
|
||||||
|
setTaskId(null);
|
||||||
|
setTaskStatus(null);
|
||||||
|
setPreview(null);
|
||||||
|
setApplyProgress(0);
|
||||||
|
setApplyMessage('');
|
||||||
|
setApplyError(null);
|
||||||
|
setIsApplyComplete(false);
|
||||||
|
message.warning('任务不存在,已清空本地缓存,请重新创建拆书任务');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelTask = async () => {
|
||||||
|
if (!taskId) return;
|
||||||
|
try {
|
||||||
|
await bookImportApi.cancelTask(taskId);
|
||||||
|
message.success('任务已取消');
|
||||||
|
await refreshStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('取消任务失败:', error);
|
||||||
|
message.error('取消任务失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyImport = async () => {
|
||||||
|
if (!taskId || !preview) return;
|
||||||
|
|
||||||
|
const payload: BookImportApplyPayload = {
|
||||||
|
project_suggestion: preview.project_suggestion,
|
||||||
|
chapters: preview.chapters,
|
||||||
|
outlines: preview.outlines,
|
||||||
|
import_mode: 'append',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setApplying(true);
|
||||||
|
setApplyProgress(0);
|
||||||
|
setApplyMessage('准备导入...');
|
||||||
|
setApplyError(null);
|
||||||
|
setIsApplyComplete(false);
|
||||||
|
setFailedSteps([]);
|
||||||
|
|
||||||
|
await bookImportApi.applyImportStream(
|
||||||
|
taskId,
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
onProgress: (msg, prog, status) => {
|
||||||
|
// 检查是否是步骤失败的特殊消息
|
||||||
|
if (status === 'step_failures') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(msg);
|
||||||
|
if (parsed.failed_steps && Array.isArray(parsed.failed_steps)) {
|
||||||
|
setFailedSteps(parsed.failed_steps as BookImportStepFailure[]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 不是JSON,忽略
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setApplyProgress(prog);
|
||||||
|
setApplyMessage(msg);
|
||||||
|
},
|
||||||
|
onResult: (result) => {
|
||||||
|
importedProjectId.current = result.project_id;
|
||||||
|
const generatedCareers = result.statistics?.generated_careers ?? 0;
|
||||||
|
const generatedEntities = result.statistics?.generated_entities ?? 0;
|
||||||
|
|
||||||
|
// 检查最终是否有失败步骤
|
||||||
|
setIsApplyComplete(true);
|
||||||
|
|
||||||
|
// 如果没有失败步骤才自动跳转
|
||||||
|
// 注意:这里需要延迟一帧来等待 failedSteps 的更新
|
||||||
|
setTimeout(() => {
|
||||||
|
setFailedSteps(prev => {
|
||||||
|
if (prev.length === 0) {
|
||||||
|
message.success(`导入成功:已生成职业${generatedCareers}个,角色/组织${generatedEntities}个`);
|
||||||
|
clearBookImportCache();
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(`/project/${result.project_id}/chapters`);
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
message.warning(`导入完成,但有 ${prev.length} 个生成步骤失败,可点击重试`);
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('导入过程发生错误:', error);
|
||||||
|
setApplyError(`导入失败: ${error}`);
|
||||||
|
message.error(`导入失败: ${error}`);
|
||||||
|
setApplying(false);
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
setApplyProgress(100);
|
||||||
|
setApplyMessage('导入完成!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('确认导入失败:', error);
|
||||||
|
setApplyError('确认导入失败,无法连接到服务器');
|
||||||
|
message.error('确认导入失败');
|
||||||
|
setApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryFailedSteps = useCallback(async () => {
|
||||||
|
if (!taskId || failedSteps.length === 0) return;
|
||||||
|
|
||||||
|
const stepsToRetry = failedSteps.map(f => f.step_name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setRetrying(true);
|
||||||
|
setRetryProgress(0);
|
||||||
|
setRetryMessage('正在重试失败的生成步骤...');
|
||||||
|
|
||||||
|
await bookImportApi.retryFailedStepsStream(
|
||||||
|
taskId,
|
||||||
|
stepsToRetry,
|
||||||
|
{
|
||||||
|
onProgress: (msg, prog, status) => {
|
||||||
|
if (status === 'step_failures') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(msg);
|
||||||
|
if (parsed.failed_steps && Array.isArray(parsed.failed_steps)) {
|
||||||
|
setFailedSteps(parsed.failed_steps as BookImportStepFailure[]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 不是JSON,忽略
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRetryProgress(prog);
|
||||||
|
setRetryMessage(msg);
|
||||||
|
},
|
||||||
|
onResult: (result) => {
|
||||||
|
if (result.still_failed && result.still_failed.length > 0) {
|
||||||
|
setFailedSteps(result.still_failed);
|
||||||
|
message.warning(`重试完成,仍有 ${result.still_failed.length} 个步骤失败`);
|
||||||
|
} else {
|
||||||
|
setFailedSteps([]);
|
||||||
|
message.success('所有步骤重试成功!');
|
||||||
|
clearBookImportCache();
|
||||||
|
const projectId = result.project_id || importedProjectId.current;
|
||||||
|
if (projectId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(`/project/${projectId}/chapters`);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('重试失败:', error);
|
||||||
|
message.error(`重试失败: ${error}`);
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
setRetrying(false);
|
||||||
|
setRetryProgress(100);
|
||||||
|
setRetryMessage('重试完成');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('重试请求失败:', error);
|
||||||
|
message.error('重试请求失败,无法连接到服务器');
|
||||||
|
setRetrying(false);
|
||||||
|
}
|
||||||
|
}, [taskId, failedSteps, navigate]);
|
||||||
|
|
||||||
|
const skipFailedSteps = useCallback(() => {
|
||||||
|
setFailedSteps([]);
|
||||||
|
clearBookImportCache();
|
||||||
|
const projectId = importedProjectId.current;
|
||||||
|
if (projectId) {
|
||||||
|
message.info('已跳过失败步骤,正在跳转到项目...');
|
||||||
|
navigate(`/project/${projectId}/chapters`);
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const updateChapter = (index: number, patch: Partial<BookImportPreview['chapters'][number]>) => {
|
||||||
|
setPreview(prev => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const next = [...prev.chapters];
|
||||||
|
next[index] = { ...next[index], ...patch };
|
||||||
|
return { ...prev, chapters: next };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', overflow: 'auto', paddingRight: 8 }}>
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
<Steps
|
||||||
|
current={currentStep}
|
||||||
|
items={[
|
||||||
|
{ title: '上传文件' },
|
||||||
|
{ title: '解析中' },
|
||||||
|
{ title: '预览修改' },
|
||||||
|
{ title: '生成导入' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{currentStep === 0 && (
|
||||||
|
<Card title="上传 TXT 并开始解析" style={{ marginBottom: 16 }}>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||||
|
<Dragger
|
||||||
|
accept=".txt"
|
||||||
|
multiple={false}
|
||||||
|
beforeUpload={(f) => {
|
||||||
|
setFile(f);
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
setFile(null);
|
||||||
|
}}
|
||||||
|
fileList={
|
||||||
|
file
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
uid: 'selected-txt',
|
||||||
|
name: file.name,
|
||||||
|
status: 'done',
|
||||||
|
} as UploadFile,
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
style={{ padding: '8px 0' }}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon">
|
||||||
|
<InboxOutlined />
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-text">点击或拖拽 TXT 文件到此区域</p>
|
||||||
|
<p className="ant-upload-hint">首版仅支持 .txt,建议不超过 50MB</p>
|
||||||
|
</Dragger>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
loading={creatingTask}
|
||||||
|
onClick={startTask}
|
||||||
|
>
|
||||||
|
开始解析
|
||||||
|
</Button>
|
||||||
|
{taskId && (
|
||||||
|
<Tag color="blue">任务ID: {taskId}</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<Card title="解析任务状态" style={{ marginBottom: 16 }}>
|
||||||
|
{!taskId ? (
|
||||||
|
<Empty description="尚未创建任务" />
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||||
|
<Progress
|
||||||
|
type="circle"
|
||||||
|
percent={taskStatus?.progress || 0}
|
||||||
|
status={
|
||||||
|
taskStatus?.status === 'failed' ? 'exception' :
|
||||||
|
taskStatus?.status === 'completed' ? 'success' :
|
||||||
|
'active'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<Text strong style={{ fontSize: 16 }}>
|
||||||
|
{taskStatus?.status === 'pending' && '等待调度...'}
|
||||||
|
{taskStatus?.status === 'running' && '正在解析TXT文件...'}
|
||||||
|
{taskStatus?.status === 'completed' && '解析完成!正在生成预览...'}
|
||||||
|
{taskStatus?.status === 'failed' && '解析失败'}
|
||||||
|
{taskStatus?.status === 'cancelled' && '已取消'}
|
||||||
|
</Text>
|
||||||
|
{taskStatus?.message && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Text type="secondary">{taskStatus.message}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{taskStatus?.error && (
|
||||||
|
<Alert type="error" message={taskStatus.error} showIcon style={{ marginTop: 16, textAlign: 'left' }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Space style={{ marginTop: 24 }}>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={refreshStatus}>刷新状态</Button>
|
||||||
|
{taskStatus && ['pending', 'running'].includes(taskStatus.status) && (
|
||||||
|
<Button danger icon={<StopOutlined />} onClick={cancelTask}>取消任务</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title="预览修正"
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={applying}
|
||||||
|
disabled={!preview}
|
||||||
|
onClick={applyImport}
|
||||||
|
>
|
||||||
|
确认导入
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<Spin spinning={loadingPreview}>
|
||||||
|
{!preview ? (
|
||||||
|
<Empty description="解析完成后将显示预览数据" />
|
||||||
|
) : (
|
||||||
|
<div style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: 8 }}>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||||
|
{preview.warnings.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="检测到告警"
|
||||||
|
description={
|
||||||
|
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||||
|
{preview.warnings.map((w, idx) => (
|
||||||
|
<li key={`${w.code}-${idx}`}>[{w.level}] {w.message}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title="项目信息"
|
||||||
|
>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Text>标题</Text>
|
||||||
|
<Input
|
||||||
|
value={preview.project_suggestion.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPreview(prev => prev ? ({
|
||||||
|
...prev,
|
||||||
|
project_suggestion: { ...prev.project_suggestion, title: e.target.value },
|
||||||
|
}) : prev)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Text>类型</Text>
|
||||||
|
<Input
|
||||||
|
value={preview.project_suggestion.genre}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPreview(prev => prev ? ({
|
||||||
|
...prev,
|
||||||
|
project_suggestion: { ...prev.project_suggestion, genre: e.target.value },
|
||||||
|
}) : prev)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24}>
|
||||||
|
<Text>主题</Text>
|
||||||
|
<TextArea
|
||||||
|
rows={3}
|
||||||
|
value={preview.project_suggestion.theme}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPreview(prev => prev ? ({
|
||||||
|
...prev,
|
||||||
|
project_suggestion: { ...prev.project_suggestion, theme: e.target.value },
|
||||||
|
}) : prev)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24}>
|
||||||
|
<Text>简介</Text>
|
||||||
|
<TextArea
|
||||||
|
rows={3}
|
||||||
|
value={preview.project_suggestion.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPreview(prev => prev ? ({
|
||||||
|
...prev,
|
||||||
|
project_suggestion: { ...prev.project_suggestion, description: e.target.value },
|
||||||
|
}) : prev)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Text>叙事角度</Text>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={preview.project_suggestion.narrative_perspective}
|
||||||
|
onChange={(v) =>
|
||||||
|
setPreview(prev => prev ? ({
|
||||||
|
...prev,
|
||||||
|
project_suggestion: { ...prev.project_suggestion, narrative_perspective: v },
|
||||||
|
}) : prev)
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: '第一人称', label: '第一人称' },
|
||||||
|
{ value: '第三人称', label: '第三人称' },
|
||||||
|
{ value: '全知视角', label: '全知视角' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Text>目标字数</Text>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={1000}
|
||||||
|
step={1000}
|
||||||
|
value={preview.project_suggestion.target_words}
|
||||||
|
onChange={(v) =>
|
||||||
|
setPreview(prev => prev ? ({
|
||||||
|
...prev,
|
||||||
|
project_suggestion: {
|
||||||
|
...prev.project_suggestion,
|
||||||
|
target_words: Number(v || 100000),
|
||||||
|
},
|
||||||
|
}) : prev)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" title={`章节(${preview.chapters.length})`}>
|
||||||
|
<Collapse
|
||||||
|
items={preview.chapters.map((ch, idx) => ({
|
||||||
|
key: String(idx),
|
||||||
|
label: `第 ${ch.chapter_number} 章 · ${ch.title}`,
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
value={ch.title}
|
||||||
|
addonBefore="标题"
|
||||||
|
onChange={(e) => updateChapter(idx, { title: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
rows={2}
|
||||||
|
value={ch.summary}
|
||||||
|
placeholder="章节摘要"
|
||||||
|
onChange={(e) => updateChapter(idx, { summary: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
rows={8}
|
||||||
|
value={ch.content}
|
||||||
|
placeholder="章节正文"
|
||||||
|
onChange={(e) => updateChapter(idx, { content: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 3 && (
|
||||||
|
<Card title="生成导入进度" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px 20px', maxWidth: 600, margin: '0 auto' }}>
|
||||||
|
<Typography.Title level={4} style={{ marginBottom: 32 }}>
|
||||||
|
{retrying ? '正在重试失败的生成步骤' : (failedSteps.length > 0 && isApplyComplete ? '导入完成,部分步骤需要重试' : '正在为您生成并导入项目内容')}
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
percent={retrying ? retryProgress : applyProgress}
|
||||||
|
status={
|
||||||
|
applyError ? 'exception' :
|
||||||
|
(failedSteps.length > 0 && isApplyComplete && !retrying) ? 'exception' :
|
||||||
|
(isApplyComplete && failedSteps.length === 0) ? 'success' :
|
||||||
|
'active'
|
||||||
|
}
|
||||||
|
strokeColor={{
|
||||||
|
'0%': 'var(--color-primary)',
|
||||||
|
'100%': failedSteps.length > 0 ? '#faad14' : 'var(--color-primary-active)',
|
||||||
|
}}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography.Paragraph
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
marginBottom: 32,
|
||||||
|
color: applyError ? 'var(--color-error)' :
|
||||||
|
(failedSteps.length > 0 && isApplyComplete && !retrying) ? '#faad14' :
|
||||||
|
'var(--color-text-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{retrying ? retryMessage : (applyError || applyMessage)}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
{applyError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message="导入出错"
|
||||||
|
description={applyError}
|
||||||
|
showIcon
|
||||||
|
style={{ textAlign: 'left', marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 步骤失败提示与重试UI */}
|
||||||
|
{failedSteps.length > 0 && isApplyComplete && !retrying && (
|
||||||
|
<div style={{ textAlign: 'left', marginBottom: 24 }}>
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
icon={<WarningOutlined />}
|
||||||
|
showIcon
|
||||||
|
message={`${failedSteps.length} 个生成步骤失败`}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<Typography.Paragraph style={{ marginBottom: 12, color: 'rgba(0,0,0,0.65)' }}>
|
||||||
|
以下AI生成步骤未能完成,但基础数据(章节、大纲)已成功导入。您可以选择重试或跳过。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
dataSource={failedSteps}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<List.Item
|
||||||
|
style={{ padding: '8px 12px' }}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<Tag color="error">{item.step_label}</Tag>
|
||||||
|
{(item.retry_count ?? 0) > 0 && (
|
||||||
|
<Tag color="orange">已重试 {item.retry_count} 次</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{item.error.length > 120 ? item.error.slice(0, 120) + '...' : item.error}
|
||||||
|
</Typography.Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Space style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<RedoOutlined />}
|
||||||
|
onClick={retryFailedSteps}
|
||||||
|
loading={retrying}
|
||||||
|
>
|
||||||
|
智能重试全部失败步骤
|
||||||
|
</Button>
|
||||||
|
<Button onClick={skipFailedSteps}>
|
||||||
|
跳过,直接进入项目
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 重试进行中 */}
|
||||||
|
{retrying && (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Spin spinning={retrying}>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="正在重试..."
|
||||||
|
description={retryMessage}
|
||||||
|
style={{ textAlign: 'left' }}
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!failedSteps.length && !retrying && (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--color-bg-layout)',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
textAlign: 'left',
|
||||||
|
marginTop: 32
|
||||||
|
}}>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
导入过程中,AI会自动帮您补全:<br />
|
||||||
|
• 世界观设定(时间、地点、氛围、规则)<br />
|
||||||
|
• 职业体系(主职业与副职业)<br />
|
||||||
|
• 核心角色与相关组织<br />
|
||||||
|
{isApplyComplete ? '所有步骤已完成,即将自动跳转。' : '请耐心等待,完成后将自动跳转。'}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import ChangelogFloatingButton from '../components/ChangelogFloatingButton';
|
|||||||
import SettingsPage from './Settings';
|
import SettingsPage from './Settings';
|
||||||
import MCPPluginsPage from './MCPPlugins';
|
import MCPPluginsPage from './MCPPlugins';
|
||||||
import PromptTemplates from './PromptTemplates';
|
import PromptTemplates from './PromptTemplates';
|
||||||
|
import BookImport from './BookImport';
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography;
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ const formatWordCount = (count: number): string => {
|
|||||||
export default function ProjectList() {
|
export default function ProjectList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { projects, loading } = useStore();
|
const { projects, loading } = useStore();
|
||||||
const [activeView, setActiveView] = useState<'projects' | 'settings' | 'mcp' | 'prompts'>('projects');
|
const [activeView, setActiveView] = useState<'projects' | 'settings' | 'mcp' | 'prompts' | 'book-import'>('projects');
|
||||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||||
const [modal, contextHolder] = Modal.useModal();
|
const [modal, contextHolder] = Modal.useModal();
|
||||||
const [showApiTip, setShowApiTip] = useState(true);
|
const [showApiTip, setShowApiTip] = useState(true);
|
||||||
@@ -400,27 +401,27 @@ export default function ProjectList() {
|
|||||||
|
|
||||||
<div style={{ padding: '0 12px', fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8, marginTop: 16 }}>创作工具</div>
|
<div style={{ padding: '0 12px', fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8, marginTop: 16 }}>创作工具</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => setActiveView('prompts')}
|
onClick={() => setActiveView('book-import')}
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 16px',
|
padding: '10px 16px',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
color: activeView === 'prompts' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
|
color: activeView === 'book-import' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
|
||||||
background: activeView === 'prompts' ? '#e6f7ff' : 'transparent',
|
background: activeView === 'book-import' ? '#e6f7ff' : 'transparent',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 10,
|
||||||
transition: 'all 0.3s',
|
transition: 'all 0.3s',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
borderRight: activeView === 'prompts' ? '3px solid var(--color-primary)' : '3px solid transparent'
|
borderRight: activeView === 'book-import' ? '3px solid var(--color-primary)' : '3px solid transparent'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
|
onMouseEnter={e => activeView !== 'book-import' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
|
||||||
onMouseLeave={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'transparent')}
|
onMouseLeave={e => activeView !== 'book-import' && (e.currentTarget.style.background = 'transparent')}
|
||||||
>
|
>
|
||||||
<FileSearchOutlined />
|
<UploadOutlined />
|
||||||
提示词管理
|
拆书导入
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => setActiveView('mcp')}
|
onClick={() => setActiveView('mcp')}
|
||||||
@@ -445,6 +446,29 @@ export default function ProjectList() {
|
|||||||
<ApiOutlined />
|
<ApiOutlined />
|
||||||
MCP 插件
|
MCP 插件
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => setActiveView('prompts')}
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 4,
|
||||||
|
color: activeView === 'prompts' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
|
||||||
|
background: activeView === 'prompts' ? '#e6f7ff' : 'transparent',
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
transition: 'all 0.3s',
|
||||||
|
marginBottom: 4,
|
||||||
|
borderRight: activeView === 'prompts' ? '3px solid var(--color-primary)' : '3px solid transparent'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
|
||||||
|
onMouseLeave={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
<FileSearchOutlined />
|
||||||
|
提示词管理
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '0 12px', fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8, marginTop: 16 }}>系统设置</div>
|
<div style={{ padding: '0 12px', fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8, marginTop: 16 }}>系统设置</div>
|
||||||
<div
|
<div
|
||||||
@@ -530,6 +554,7 @@ export default function ProjectList() {
|
|||||||
}}>
|
}}>
|
||||||
{activeView === 'projects' ? '我的书架' :
|
{activeView === 'projects' ? '我的书架' :
|
||||||
activeView === 'prompts' ? '提示词模板' :
|
activeView === 'prompts' ? '提示词模板' :
|
||||||
|
activeView === 'book-import' ? '拆书导入' :
|
||||||
activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
|
activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -577,7 +602,7 @@ export default function ProjectList() {
|
|||||||
selectedKeys={[activeView]}
|
selectedKeys={[activeView]}
|
||||||
style={{ borderRight: 0, paddingTop: 8 }}
|
style={{ borderRight: 0, paddingTop: 8 }}
|
||||||
onClick={({ key }) => {
|
onClick={({ key }) => {
|
||||||
setActiveView(key as 'projects' | 'settings' | 'mcp' | 'prompts');
|
setActiveView(key as 'projects' | 'settings' | 'mcp' | 'prompts' | 'book-import');
|
||||||
setDrawerVisible(false);
|
setDrawerVisible(false);
|
||||||
}}
|
}}
|
||||||
items={[
|
items={[
|
||||||
@@ -591,15 +616,20 @@ export default function ProjectList() {
|
|||||||
label: '创作工具',
|
label: '创作工具',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
key: 'prompts',
|
key: 'book-import',
|
||||||
icon: <FileSearchOutlined />,
|
icon: <UploadOutlined />,
|
||||||
label: '提示词管理',
|
label: '拆书导入',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'mcp',
|
key: 'mcp',
|
||||||
icon: <ApiOutlined />,
|
icon: <ApiOutlined />,
|
||||||
label: 'MCP 插件',
|
label: 'MCP 插件',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'prompts',
|
||||||
|
icon: <FileSearchOutlined />,
|
||||||
|
label: '提示词管理',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -676,6 +706,7 @@ export default function ProjectList() {
|
|||||||
}}>
|
}}>
|
||||||
{activeView === 'projects' ? '我的书架' :
|
{activeView === 'projects' ? '我的书架' :
|
||||||
activeView === 'prompts' ? '提示词模板' :
|
activeView === 'prompts' ? '提示词模板' :
|
||||||
|
activeView === 'book-import' ? '拆书导入' :
|
||||||
activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
|
activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -743,7 +774,9 @@ export default function ProjectList() {
|
|||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
padding: activeView === 'projects' ? `${isMobile ? 16 : 24}px ${isMobile ? 16 : 32}px` : 0,
|
padding: (activeView === 'projects' || activeView === 'book-import')
|
||||||
|
? `${isMobile ? 16 : 24}px ${isMobile ? 16 : 32}px`
|
||||||
|
: 0,
|
||||||
background: 'var(--color-bg-base)',
|
background: 'var(--color-bg-base)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -751,6 +784,12 @@ export default function ProjectList() {
|
|||||||
{activeView === 'mcp' && <MCPPluginsPage />}
|
{activeView === 'mcp' && <MCPPluginsPage />}
|
||||||
{activeView === 'prompts' && <PromptTemplates />}
|
{activeView === 'prompts' && <PromptTemplates />}
|
||||||
|
|
||||||
|
{activeView === 'book-import' && (
|
||||||
|
<div style={{ maxWidth: 1200, margin: '0 auto', paddingBottom: 60 }}>
|
||||||
|
<BookImport />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeView === 'projects' && (
|
{activeView === 'projects' && (
|
||||||
<div style={{ maxWidth: 1600, margin: '0 auto', paddingBottom: 60 }}>
|
<div style={{ maxWidth: 1600, margin: '0 auto', paddingBottom: 60 }}>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user