diff --git "a/backend/alembic/postgres/versions/20260325_1200_9f1b2c3d4e5f_\346\226\260\345\242\236\351\241\271\347\233\256\350\207\252\345\212\250\346\216\250\350\277\233\350\256\241\345\210\222.py" "b/backend/alembic/postgres/versions/20260325_1200_9f1b2c3d4e5f_\346\226\260\345\242\236\351\241\271\347\233\256\350\207\252\345\212\250\346\216\250\350\277\233\350\256\241\345\210\222.py" new file mode 100644 index 00000000..393bf067 --- /dev/null +++ "b/backend/alembic/postgres/versions/20260325_1200_9f1b2c3d4e5f_\346\226\260\345\242\236\351\241\271\347\233\256\350\207\252\345\212\250\346\216\250\350\277\233\350\256\241\345\210\222.py" @@ -0,0 +1,70 @@ +"""新增项目自动推进计划 + +Revision ID: 9f1b2c3d4e5f +Revises: 6eb27fce64de +Create Date: 2026-03-25 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9f1b2c3d4e5f' +down_revision: Union[str, None] = '6eb27fce64de' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'project_generation_schedules', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False, comment='项目ID'), + sa.Column('user_id', sa.String(length=100), nullable=False, comment='用户ID'), + sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.text('false'), comment='是否启用自动推进'), + sa.Column('cron_expr', sa.String(length=100), nullable=False, server_default='0 9 * * *', comment='Cron表达式'), + sa.Column('timezone', sa.String(length=100), nullable=False, server_default='Asia/Shanghai', comment='时区'), + sa.Column('chapters_per_run', sa.Integer(), nullable=False, server_default='1', comment='每次触发生成章节数'), + sa.Column('target_word_count', sa.Integer(), nullable=False, server_default='3000', comment='目标字数'), + sa.Column('enable_analysis', sa.Boolean(), nullable=False, server_default=sa.text('false'), comment='是否启用同步分析'), + sa.Column('enable_mcp', sa.Boolean(), nullable=False, server_default=sa.text('false'), comment='是否启用MCP增强'), + sa.Column('max_retries', sa.Integer(), nullable=False, server_default='3', comment='最大重试次数'), + sa.Column('model', sa.String(length=100), nullable=True, comment='指定使用的模型'), + sa.Column('min_ready_chapters', sa.Integer(), nullable=False, server_default='3', comment='最少待写章节缓冲数'), + sa.Column('outline_batch_size', sa.Integer(), nullable=False, server_default='1', comment='每次补充大纲数量'), + sa.Column('chapters_per_outline', sa.Integer(), nullable=False, server_default='3', comment='每个大纲默认展开章节数'), + sa.Column('expansion_strategy', sa.String(length=50), nullable=False, server_default='balanced', comment='展开策略'), + sa.Column('enable_scene_analysis', sa.Boolean(), nullable=False, server_default=sa.text('false'), comment='是否启用场景分析'), + sa.Column('last_pipeline_stage', sa.String(length=50), nullable=True, comment='最近一次执行到的流水线阶段'), + sa.Column('next_run_at', sa.DateTime(), nullable=True, comment='下一次执行时间'), + sa.Column('last_triggered_at', sa.DateTime(), nullable=True, comment='最近触发时间'), + sa.Column('last_finished_at', sa.DateTime(), nullable=True, comment='最近完成时间'), + sa.Column('last_run_status', sa.String(length=50), nullable=True, comment='最近运行状态'), + sa.Column('last_error', sa.Text(), nullable=True, comment='最近错误信息'), + sa.Column('current_batch_task_id', sa.String(length=36), nullable=True, comment='当前关联的批量任务ID'), + sa.Column('created_at', sa.DateTime(), nullable=True, server_default=sa.text('now()'), comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), nullable=True, server_default=sa.text('now()'), comment='更新时间'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('project_id') + ) + op.create_index(op.f('ix_project_generation_schedules_user_id'), 'project_generation_schedules', ['user_id'], unique=False) + op.create_index('idx_project_generation_schedules_enabled_next_run_at', 'project_generation_schedules', ['enabled', 'next_run_at'], unique=False) + + op.add_column('batch_generation_tasks', sa.Column('enable_mcp', sa.Boolean(), nullable=False, server_default=sa.text('true'), comment='是否启用MCP工具增强')) + op.add_column('batch_generation_tasks', sa.Column('model_name', sa.String(length=100), nullable=True, comment='指定使用的AI模型')) + op.add_column('batch_generation_tasks', sa.Column('trigger_source', sa.String(length=30), nullable=False, server_default='manual', comment='任务触发来源: manual/schedule')) + op.add_column('batch_generation_tasks', sa.Column('schedule_id', sa.String(length=36), nullable=True, comment='关联的自动推进计划ID')) + + +def downgrade() -> None: + op.drop_column('batch_generation_tasks', 'schedule_id') + op.drop_column('batch_generation_tasks', 'trigger_source') + op.drop_column('batch_generation_tasks', 'model_name') + op.drop_column('batch_generation_tasks', 'enable_mcp') + + op.drop_index('idx_project_generation_schedules_enabled_next_run_at', table_name='project_generation_schedules') + op.drop_index(op.f('ix_project_generation_schedules_user_id'), table_name='project_generation_schedules') + op.drop_table('project_generation_schedules') diff --git "a/backend/alembic/sqlite/versions/20260325_1201_a1b2c3d4e5f6_\346\226\260\345\242\236\351\241\271\347\233\256\350\207\252\345\212\250\346\216\250\350\277\233\350\256\241\345\210\222.py" "b/backend/alembic/sqlite/versions/20260325_1201_a1b2c3d4e5f6_\346\226\260\345\242\236\351\241\271\347\233\256\350\207\252\345\212\250\346\216\250\350\277\233\350\256\241\345\210\222.py" new file mode 100644 index 00000000..b2ab9175 --- /dev/null +++ "b/backend/alembic/sqlite/versions/20260325_1201_a1b2c3d4e5f6_\346\226\260\345\242\236\351\241\271\347\233\256\350\207\252\345\212\250\346\216\250\350\277\233\350\256\241\345\210\222.py" @@ -0,0 +1,72 @@ +"""新增项目自动推进计划 + +Revision ID: a1b2c3d4e5f6 +Revises: 6ff45db05863 +Create Date: 2026-03-25 12:01:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, None] = '6ff45db05863' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'project_generation_schedules', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False, comment='项目ID'), + sa.Column('user_id', sa.String(length=100), nullable=False, comment='用户ID'), + sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.text('0'), comment='是否启用自动推进'), + sa.Column('cron_expr', sa.String(length=100), nullable=False, server_default='0 9 * * *', comment='Cron表达式'), + sa.Column('timezone', sa.String(length=100), nullable=False, server_default='Asia/Shanghai', comment='时区'), + sa.Column('chapters_per_run', sa.Integer(), nullable=False, server_default='1', comment='每次触发生成章节数'), + sa.Column('target_word_count', sa.Integer(), nullable=False, server_default='3000', comment='目标字数'), + sa.Column('enable_analysis', sa.Boolean(), nullable=False, server_default=sa.text('0'), comment='是否启用同步分析'), + sa.Column('enable_mcp', sa.Boolean(), nullable=False, server_default=sa.text('0'), comment='是否启用MCP增强'), + sa.Column('max_retries', sa.Integer(), nullable=False, server_default='3', comment='最大重试次数'), + sa.Column('model', sa.String(length=100), nullable=True, comment='指定使用的模型'), + sa.Column('min_ready_chapters', sa.Integer(), nullable=False, server_default='3', comment='最少待写章节缓冲数'), + sa.Column('outline_batch_size', sa.Integer(), nullable=False, server_default='1', comment='每次补充大纲数量'), + sa.Column('chapters_per_outline', sa.Integer(), nullable=False, server_default='3', comment='每个大纲默认展开章节数'), + sa.Column('expansion_strategy', sa.String(length=50), nullable=False, server_default='balanced', comment='展开策略'), + sa.Column('enable_scene_analysis', sa.Boolean(), nullable=False, server_default=sa.text('0'), comment='是否启用场景分析'), + sa.Column('last_pipeline_stage', sa.String(length=50), nullable=True, comment='最近一次执行到的流水线阶段'), + sa.Column('next_run_at', sa.DateTime(), nullable=True, comment='下一次执行时间'), + sa.Column('last_triggered_at', sa.DateTime(), nullable=True, comment='最近触发时间'), + sa.Column('last_finished_at', sa.DateTime(), nullable=True, comment='最近完成时间'), + sa.Column('last_run_status', sa.String(length=50), nullable=True, comment='最近运行状态'), + sa.Column('last_error', sa.Text(), nullable=True, comment='最近错误信息'), + sa.Column('current_batch_task_id', sa.String(length=36), nullable=True, comment='当前关联的批量任务ID'), + sa.Column('created_at', sa.DateTime(), nullable=True, server_default=sa.text('CURRENT_TIMESTAMP'), comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), nullable=True, server_default=sa.text('CURRENT_TIMESTAMP'), comment='更新时间'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('project_id') + ) + op.create_index('ix_project_generation_schedules_user_id', 'project_generation_schedules', ['user_id'], unique=False) + op.create_index('idx_project_generation_schedules_enabled_next_run_at', 'project_generation_schedules', ['enabled', 'next_run_at'], unique=False) + + with op.batch_alter_table('batch_generation_tasks', schema=None) as batch_op: + batch_op.add_column(sa.Column('enable_mcp', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='是否启用MCP工具增强')) + batch_op.add_column(sa.Column('model_name', sa.String(length=100), nullable=True, comment='指定使用的AI模型')) + batch_op.add_column(sa.Column('trigger_source', sa.String(length=30), nullable=False, server_default='manual', comment='任务触发来源: manual/schedule')) + batch_op.add_column(sa.Column('schedule_id', sa.String(length=36), nullable=True, comment='关联的自动推进计划ID')) + + +def downgrade() -> None: + with op.batch_alter_table('batch_generation_tasks', schema=None) as batch_op: + batch_op.drop_column('schedule_id') + batch_op.drop_column('trigger_source') + batch_op.drop_column('model_name') + batch_op.drop_column('enable_mcp') + + op.drop_index('idx_project_generation_schedules_enabled_next_run_at', table_name='project_generation_schedules') + op.drop_index('ix_project_generation_schedules_user_id', table_name='project_generation_schedules') + op.drop_table('project_generation_schedules') diff --git a/backend/app/api/project_generation_schedule.py b/backend/app/api/project_generation_schedule.py new file mode 100644 index 00000000..097d74a6 --- /dev/null +++ b/backend/app/api/project_generation_schedule.py @@ -0,0 +1,148 @@ +"""项目自动推进计划 API""" +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.project import Project +from app.models.project_generation_schedule import ProjectGenerationSchedule +from app.schemas.project_generation_schedule import ( + ProjectGenerationScheduleResponse, + ProjectGenerationScheduleUpdate, +) +from app.services.project_generation_automation_service import project_generation_automation_service + +router = APIRouter(prefix="/project-automation", tags=["项目自动推进"]) + + +def _get_current_user_id(request: Request) -> str: + """从请求上下文中获取当前用户 ID。""" + user_id = getattr(request.state, "user_id", None) + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + return user_id + + +async def _get_project(project_id: str, user_id: str, db: AsyncSession) -> Project: + """获取当前用户有权访问的项目。""" + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == user_id) + ) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + return project + + +async def _get_schedule_by_project_id( + project_id: str, + user_id: str, + db: AsyncSession, +) -> ProjectGenerationSchedule | None: + """按项目 ID 获取自动推进计划。""" + result = await db.execute( + select(ProjectGenerationSchedule).where( + ProjectGenerationSchedule.project_id == project_id, + ProjectGenerationSchedule.user_id == user_id, + ) + ) + return result.scalar_one_or_none() + + +def _serialize_schedule( + schedule: ProjectGenerationSchedule, + outline_mode: str, +) -> ProjectGenerationScheduleResponse: + """序列化自动推进计划响应。""" + return ProjectGenerationScheduleResponse( + **{ + **ProjectGenerationScheduleResponse.model_validate(schedule).model_dump(), + "outline_mode": outline_mode, + } + ) + + +@router.get("/{project_id}", response_model=ProjectGenerationScheduleResponse, summary="获取项目自动推进计划") +async def get_project_generation_schedule( + project_id: str, + request: Request, + db: AsyncSession = Depends(get_db), +) -> ProjectGenerationScheduleResponse: + user_id = _get_current_user_id(request) + project = await _get_project(project_id, user_id, db) + outline_mode = project.outline_mode + schedule = await _get_schedule_by_project_id(project_id, user_id, db) + if not schedule: + raise HTTPException(status_code=404, detail="自动推进计划不存在") + return _serialize_schedule(schedule, outline_mode) + + +@router.put("/{project_id}", response_model=ProjectGenerationScheduleResponse, summary="创建或更新项目自动推进计划") +async def save_project_generation_schedule( + project_id: str, + payload: ProjectGenerationScheduleUpdate, + request: Request, + db: AsyncSession = Depends(get_db), +) -> ProjectGenerationScheduleResponse: + user_id = _get_current_user_id(request) + project = await _get_project(project_id, user_id, db) + outline_mode = project.outline_mode + schedule = await _get_schedule_by_project_id(project_id, user_id, db) + + data = payload.model_dump() + model_name = (data.get("model") or "").strip() + data["model"] = model_name or None + try: + data["next_run_at"] = project_generation_automation_service.calculate_next_run_at( + data["cron_expr"], data["timezone"] + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if schedule is None: + schedule = ProjectGenerationSchedule(project_id=project_id, user_id=user_id, **data) + db.add(schedule) + else: + for field, value in data.items(): + setattr(schedule, field, value) + + await db.commit() + await db.refresh(schedule) + return _serialize_schedule(schedule, outline_mode) + + +@router.post("/{project_id}/trigger", response_model=ProjectGenerationScheduleResponse, summary="立即执行一次项目自动推进") +async def trigger_project_generation_schedule( + project_id: str, + request: Request, + db: AsyncSession = Depends(get_db), +) -> ProjectGenerationScheduleResponse: + user_id = _get_current_user_id(request) + project = await _get_project(project_id, user_id, db) + schedule = await _get_schedule_by_project_id(project_id, user_id, db) + if not schedule: + raise HTTPException(status_code=404, detail="自动推进计划不存在") + + outline_mode = project.outline_mode + refreshed_schedule = await project_generation_automation_service.run_project_automation(schedule.id, db) + if refreshed_schedule is None: + raise HTTPException(status_code=404, detail="自动推进计划不存在") + + return _serialize_schedule(refreshed_schedule, outline_mode) + + +@router.delete("/{project_id}", summary="删除项目自动推进计划") +async def delete_project_generation_schedule( + project_id: str, + request: Request, + db: AsyncSession = Depends(get_db), +) -> dict[str, str]: + user_id = _get_current_user_id(request) + await _get_project(project_id, user_id, db) + schedule = await _get_schedule_by_project_id(project_id, user_id, db) + if not schedule: + raise HTTPException(status_code=404, detail="自动推进计划不存在") + + await db.delete(schedule) + await db.commit() + return {"message": "项目自动推进计划已删除", "project_id": project_id} diff --git a/backend/app/database.py b/backend/app/database.py index 4a9c1385..33bc0a0b 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -21,8 +21,8 @@ Settings, WritingStyle, ProjectDefaultStyle, RelationshipType, CharacterRelationship, Organization, OrganizationMember, StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask, - RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate, - BackgroundTask, Announcement + ProjectGenerationSchedule, RegenerationTask, Career, CharacterCareer, + User, MCPPlugin, PromptTemplate, BackgroundTask, Announcement ) # 引擎缓存:每个用户一个引擎 diff --git a/backend/app/main.py b/backend/app/main.py index 142076e7..e045ecb9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,6 +13,10 @@ from app.middleware import RequestIDMiddleware from app.middleware.auth_middleware import AuthMiddleware from app.mcp import mcp_client, register_status_sync +from app.services.project_generation_scheduler import ( + start_project_generation_scheduler, + stop_project_generation_scheduler, +) setup_logging( level=config_settings.log_level, @@ -44,9 +48,13 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning(f"后台任务表检查失败(不影响启动): {e}") + start_project_generation_scheduler() + logger.info("应用启动完成") - + yield + + await stop_project_generation_scheduler() # 清理MCP插件 await mcp_client.cleanup() @@ -147,7 +155,8 @@ async def db_session_stats(request: Request): auth, users, settings, writing_styles, memories, mcp_plugins, admin, inspiration, prompt_templates, changelog, careers, foreshadows, prompt_workshop, book_import, - project_covers, tasks, skills, announcements + project_covers, tasks, skills, announcements, + project_generation_schedule ) app.include_router(auth.router, prefix="/api") @@ -157,6 +166,7 @@ async def db_session_stats(request: Request): app.include_router(projects.router, prefix="/api") app.include_router(project_covers.router, prefix="/api") +app.include_router(project_generation_schedule.router, prefix="/api") app.include_router(wizard_stream.router, prefix="/api") app.include_router(inspiration.router, prefix="/api") app.include_router(outlines.router, prefix="/api") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index de46aa9c..f9062e2e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.generation_history import GenerationHistory from app.models.analysis_task import AnalysisTask from app.models.batch_generation_task import BatchGenerationTask +from app.models.project_generation_schedule import ProjectGenerationSchedule from app.models.settings import Settings from app.models.memory import StoryMemory, PlotAnalysis from app.models.writing_style import WritingStyle @@ -33,6 +34,7 @@ "GenerationHistory", "AnalysisTask", "BatchGenerationTask", + "ProjectGenerationSchedule", "Settings", "StoryMemory", "PlotAnalysis", diff --git a/backend/app/models/project_generation_schedule.py b/backend/app/models/project_generation_schedule.py new file mode 100644 index 00000000..f7742612 --- /dev/null +++ b/backend/app/models/project_generation_schedule.py @@ -0,0 +1,50 @@ +"""项目自动推进计划数据模型""" +import uuid + +from sqlalchemy import Boolean, Column, DateTime, Index, Integer, String, Text +from sqlalchemy.sql import func + +from app.database import Base + + +class ProjectGenerationSchedule(Base): + """项目自动推进计划表""" + + __tablename__ = "project_generation_schedules" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + project_id = Column(String(36), nullable=False, unique=True, comment="项目ID") + user_id = Column(String(100), nullable=False, index=True, comment="用户ID") + + enabled = Column(Boolean, default=False, server_default="0", nullable=False, comment="是否启用自动推进") + cron_expr = Column(String(100), default="0 9 * * *", server_default="0 9 * * *", nullable=False, comment="Cron表达式") + timezone = Column(String(100), default="Asia/Shanghai", server_default="Asia/Shanghai", nullable=False, comment="时区") + chapters_per_run = Column(Integer, default=1, server_default="1", nullable=False, comment="每次触发生成章节数") + target_word_count = Column(Integer, default=3000, server_default="3000", nullable=False, comment="目标字数") + enable_analysis = Column(Boolean, default=False, server_default="0", nullable=False, comment="是否启用同步分析") + enable_mcp = Column(Boolean, default=False, server_default="0", nullable=False, comment="是否启用MCP增强") + max_retries = Column(Integer, default=3, server_default="3", nullable=False, comment="最大重试次数") + model = Column(String(100), comment="指定使用的模型") + min_ready_chapters = Column(Integer, default=3, server_default="3", nullable=False, comment="最少待写章节缓冲数") + outline_batch_size = Column(Integer, default=1, server_default="1", nullable=False, comment="每次补充大纲数量") + chapters_per_outline = Column(Integer, default=3, server_default="3", nullable=False, comment="每个大纲默认展开章节数") + expansion_strategy = Column(String(50), default="balanced", server_default="balanced", nullable=False, comment="展开策略") + enable_scene_analysis = Column(Boolean, default=False, server_default="0", nullable=False, comment="是否启用场景分析") + + last_pipeline_stage = Column(String(50), comment="最近一次执行到的流水线阶段") + next_run_at = Column(DateTime, comment="下一次执行时间") + last_triggered_at = Column(DateTime, comment="最近触发时间") + last_finished_at = Column(DateTime, comment="最近完成时间") + last_run_status = Column(String(50), comment="最近运行状态") + last_error = Column(Text, comment="最近错误信息") + current_batch_task_id = Column(String(36), comment="当前关联的批量任务ID") + + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + __table_args__ = ( + Index("idx_project_generation_schedules_enabled_next_run_at", "enabled", "next_run_at"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/schemas/project_generation_schedule.py b/backend/app/schemas/project_generation_schedule.py new file mode 100644 index 00000000..12e98b65 --- /dev/null +++ b/backend/app/schemas/project_generation_schedule.py @@ -0,0 +1,52 @@ +"""项目自动推进计划相关的 Pydantic 模型""" +from datetime import datetime +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ProjectGenerationScheduleBase(BaseModel): + """项目自动推进计划基础模型""" + + model_config = ConfigDict(protected_namespaces=()) + + enabled: bool = Field(default=False, description="是否启用自动推进") + cron_expr: str = Field(default="0 9 * * *", min_length=1, max_length=100, description="Cron 表达式") + timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=100, description="时区") + chapters_per_run: int = Field(default=1, ge=1, le=20, description="每次触发生成章节数") + target_word_count: int = Field(default=3000, ge=100, le=50000, description="目标字数") + enable_analysis: bool = Field(default=False, description="是否启用同步分析") + enable_mcp: bool = Field(default=False, description="是否启用 MCP 增强") + max_retries: int = Field(default=3, ge=0, le=10, description="最大重试次数") + model: Optional[str] = Field(default=None, max_length=100, description="指定使用的模型") + min_ready_chapters: int = Field(default=3, ge=1, le=50, description="最少待写章节缓冲数") + outline_batch_size: int = Field(default=1, ge=1, le=10, description="每次补充大纲数量") + chapters_per_outline: int = Field(default=3, ge=1, le=10, description="每个大纲默认展开章节数") + expansion_strategy: Literal["balanced", "climax", "detail"] = Field( + default="balanced", description="展开策略:balanced=均衡展开,climax=高潮优先,detail=细节展开" + ) + enable_scene_analysis: bool = Field(default=False, description="是否启用场景分析") + + +class ProjectGenerationScheduleUpdate(ProjectGenerationScheduleBase): + """项目自动推进计划更新模型""" + + +class ProjectGenerationScheduleResponse(ProjectGenerationScheduleBase): + """项目自动推进计划响应模型""" + + model_config = ConfigDict(from_attributes=True, protected_namespaces=()) + + id: str + project_id: str + user_id: str + outline_mode: Optional[Literal["one-to-one", "one-to-many"]] = None + last_pipeline_stage: Optional[str] = None + next_run_at: Optional[datetime] = None + last_triggered_at: Optional[datetime] = None + last_finished_at: Optional[datetime] = None + last_run_status: Optional[str] = None + last_error: Optional[str] = None + current_batch_task_id: Optional[str] = None + created_at: datetime + updated_at: datetime diff --git a/backend/app/services/project_generation_automation_service.py b/backend/app/services/project_generation_automation_service.py new file mode 100644 index 00000000..2d3595b7 --- /dev/null +++ b/backend/app/services/project_generation_automation_service.py @@ -0,0 +1,911 @@ +"""项目自动推进编排服务。""" +from __future__ import annotations + +import asyncio +import json +from datetime import datetime, timedelta, timezone +from typing import Any, Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from croniter import croniter +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.api.chapters import check_prerequisites, execute_batch_generation_in_order +from app.config import settings as config_settings +from app.database import get_engine +from app.logger import get_logger +from app.models.batch_generation_task import BatchGenerationTask +from app.models.chapter import Chapter +from app.models.character import Character +from app.models.mcp_plugin import MCPPlugin +from app.models.outline import Outline +from app.models.project import Project +from app.models.project_generation_schedule import ProjectGenerationSchedule +from app.models.settings import Settings +from app.services.ai_service import AIService, create_user_ai_service_with_mcp +from app.services.plot_expansion_service import PlotExpansionService +from app.services.prompt_service import PromptService + +logger = get_logger(__name__) + + +class ProjectGenerationAutomationService: + """项目自动推进编排服务。""" + + def calculate_next_run_at(self, cron_expr: str, timezone_name: str) -> datetime: + """根据 Cron 表达式和时区计算下一次执行时间。 + + Raises: + ValueError: 时区或 Cron 表达式无效时抛出。 + """ + try: + tzinfo = ZoneInfo(timezone_name) + except ZoneInfoNotFoundError as exc: + raise ValueError("无效的时区配置") from exc + + now = datetime.now(tzinfo) + try: + next_run_at = croniter(cron_expr, now).get_next(datetime) + except (ValueError, KeyError) as exc: + raise ValueError("Cron 表达式无效") from exc + + next_run_at_utc = next_run_at.astimezone(timezone.utc) + return next_run_at_utc.replace(tzinfo=None) + + async def _get_schedule( + self, + schedule_id: str, + db: AsyncSession, + ) -> ProjectGenerationSchedule | None: + """获取自动推进计划。""" + result = await db.execute( + select(ProjectGenerationSchedule).where(ProjectGenerationSchedule.id == schedule_id) + ) + return result.scalar_one_or_none() + + async def _get_active_batch_task( + self, + project_id: str, + db: AsyncSession, + ) -> BatchGenerationTask | None: + """获取项目当前活跃的正文批量任务。""" + result = await db.execute( + select(BatchGenerationTask) + .where(BatchGenerationTask.project_id == project_id) + .where(BatchGenerationTask.status.in_(["pending", "running"])) + .order_by(BatchGenerationTask.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + @staticmethod + def _is_pending_content_chapter(chapter: Chapter) -> bool: + """判断章节是否仍待生成正文。""" + return not chapter.content or not chapter.content.strip() + + async def collect_project_generation_state( + self, + schedule: ProjectGenerationSchedule, + db: AsyncSession, + ) -> dict[str, Any]: + """收集项目当前自动推进所需状态。""" + project_result = await db.execute( + select(Project).where(Project.id == schedule.project_id) + ) + project = project_result.scalar_one_or_none() + if project is None: + raise ValueError("项目不存在") + + outlines_result = await db.execute( + select(Outline) + .where(Outline.project_id == schedule.project_id) + .order_by(Outline.order_index.asc(), Outline.created_at.asc()) + ) + outlines = outlines_result.scalars().all() + + chapters_result = await db.execute( + select(Chapter) + .where(Chapter.project_id == schedule.project_id) + .order_by(Chapter.chapter_number.asc(), Chapter.sub_index.asc(), Chapter.created_at.asc()) + ) + chapters = chapters_result.scalars().all() + + pending_content_chapters = [ + chapter for chapter in chapters if self._is_pending_content_chapter(chapter) + ] + + existing_chapter_numbers = {chapter.chapter_number for chapter in chapters} + outline_ids_with_chapters = { + chapter.outline_id for chapter in chapters if chapter.outline_id + } + + missing_one_to_one_outlines = [ + outline + for outline in outlines + if outline.order_index is not None and outline.order_index not in existing_chapter_numbers + ] + unexpanded_outlines = [ + outline for outline in outlines if outline.id not in outline_ids_with_chapters + ] + + return { + "project": project, + "outlines": outlines, + "chapters": chapters, + "pending_content_chapters": pending_content_chapters, + "missing_one_to_one_outlines": missing_one_to_one_outlines, + "unexpanded_outlines": unexpanded_outlines, + "active_batch_task": await self._get_active_batch_task(schedule.project_id, db), + } + + async def _create_missing_one_to_one_chapters( + self, + project_id: str, + outlines: list[Outline], + chapters: list[Chapter], + db: AsyncSession, + ) -> list[Chapter]: + """为 one-to-one 项目补齐缺失的章节记录。""" + existing_numbers = {chapter.chapter_number for chapter in chapters} + created_chapters: list[Chapter] = [] + + for outline in outlines: + if outline.order_index is None or outline.order_index in existing_numbers: + continue + + chapter = Chapter( + project_id=project_id, + title=outline.title or f"第{outline.order_index}章", + summary=outline.content or "", + chapter_number=outline.order_index, + sub_index=1, + outline_id=None, + status="pending", + content="", + ) + db.add(chapter) + created_chapters.append(chapter) + existing_numbers.add(outline.order_index) + + if created_chapters: + await db.flush() + for chapter in created_chapters: + await db.refresh(chapter) + + return created_chapters + + async def _create_scheduled_batch_task( + self, + schedule: ProjectGenerationSchedule, + chapters_to_generate: list[Chapter], + db: AsyncSession, + ) -> BatchGenerationTask: + """创建由调度器触发的正文批量任务。""" + batch_task = BatchGenerationTask( + project_id=schedule.project_id, + user_id=schedule.user_id, + start_chapter_number=chapters_to_generate[0].chapter_number, + chapter_count=len(chapters_to_generate), + chapter_ids=[chapter.id for chapter in chapters_to_generate], + style_id=None, + target_word_count=schedule.target_word_count, + enable_analysis=schedule.enable_analysis, + enable_mcp=schedule.enable_mcp, + model_name=schedule.model, + trigger_source="schedule", + schedule_id=schedule.id, + max_retries=schedule.max_retries, + status="pending", + total_chapters=len(chapters_to_generate), + completed_chapters=0, + failed_chapters=[], + current_retry_count=0, + ) + db.add(batch_task) + await db.flush() + await db.refresh(batch_task) + return batch_task + + @staticmethod + def _build_characters_info(characters: list[Character]) -> str: + """构建角色信息字符串。""" + return "\n".join([ + f"- {char.name} ({'组织' if char.is_organization else '角色'}, {char.role_type}): " + f"{char.personality[:100] if char.personality else '暂无描述'}" + for char in characters + ]) + + @staticmethod + def _serialize_outline_context(outline: Outline) -> str: + """序列化单个大纲上下文。""" + outline_text = f"\n第{outline.order_index}章《{outline.title}》" + if not outline.structure: + if outline.content: + outline_text += f"\n 概要:{outline.content}" + return outline_text + + try: + structure_data = json.loads(outline.structure) + except json.JSONDecodeError: + if outline.content: + outline_text += f"\n 概要:{outline.content}" + return outline_text + + if structure_data.get("summary"): + outline_text += f"\n 概要:{structure_data['summary']}" + elif structure_data.get("content"): + outline_text += f"\n 概要:{structure_data['content']}" + + if structure_data.get("key_points"): + events = structure_data["key_points"] + if isinstance(events, list): + outline_text += f"\n 关键事件:{', '.join(events)}" + else: + outline_text += f"\n 关键事件:{events}" + + if structure_data.get("characters"): + chars = structure_data["characters"] + if isinstance(chars, list): + char_names: list[str] = [] + org_names: list[str] = [] + for item in chars: + if isinstance(item, dict): + name = item.get("name", "") + if not name: + continue + if item.get("type") == "organization": + org_names.append(name) + else: + char_names.append(name) + elif isinstance(item, str): + char_names.append(item) + if char_names: + outline_text += f"\n 重点角色:{', '.join(char_names)}" + if org_names: + outline_text += f"\n 涉及组织:{', '.join(org_names)}" + else: + outline_text += f"\n 重点角色:{chars}" + + if structure_data.get("emotion"): + outline_text += f"\n 情感基调:{structure_data['emotion']}" + if structure_data.get("goal"): + outline_text += f"\n 叙事目标:{structure_data['goal']}" + + return outline_text + + def _build_outline_continue_context( + self, + project: Project, + latest_outlines: list[Outline], + characters: list[Character], + ) -> dict[str, Any]: + """构建自动续写大纲所需上下文。""" + recent_outlines = latest_outlines[-10:] if len(latest_outlines) > 10 else latest_outlines + outline_texts = [f"【最近{len(recent_outlines)}章大纲详情】"] if recent_outlines else [] + for outline in recent_outlines: + outline_texts.append(self._serialize_outline_context(outline)) + + return { + "recent_outlines": "\n".join(outline_texts), + "characters_info": self._build_characters_info(characters) or "暂无角色信息", + } + + async def _save_outlines( + self, + project_id: str, + outline_data: list[dict[str, Any]], + db: AsyncSession, + start_index: int, + ) -> list[Outline]: + """保存自动生成的大纲。""" + outlines: list[Outline] = [] + for idx, chapter_data in enumerate(outline_data): + order_idx = chapter_data.get("chapter_number", start_index + idx) + chapter_title = chapter_data.get("title", f"第{order_idx}章") + chapter_content = chapter_data.get("summary") or chapter_data.get("content", "") + outline = Outline( + project_id=project_id, + title=chapter_title, + content=chapter_content, + structure=json.dumps(chapter_data, ensure_ascii=False), + order_index=order_idx, + ) + db.add(outline) + outlines.append(outline) + + await db.flush() + for outline in outlines: + await db.refresh(outline) + return outlines + + @staticmethod + def _build_all_chapters_brief(chapters: list[Chapter], outlines: list[Outline]) -> str: + """构建所有章节的简要概览。""" + if not chapters and not outlines: + return "暂无章节" + brief_parts: list[str] = [] + for chapter in chapters: + summary = (chapter.summary or "")[:80] + brief_parts.append(f"第{chapter.chapter_number}章《{chapter.title or '未命名'}》:{summary}") + for outline in outlines: + if outline.order_index is not None: + content_brief = (outline.content or "")[:80] + brief_parts.append(f"第{outline.order_index}章《{outline.title or '未命名'}》(大纲):{content_brief}") + return "\n".join(brief_parts) if brief_parts else "暂无章节" + + async def _generate_initial_outlines( + self, + schedule: ProjectGenerationSchedule, + project: Project, + db: AsyncSession, + ) -> list[Outline]: + """为无大纲项目生成首批大纲。""" + ai_service = await self._build_user_ai_service(schedule.user_id, db) + ai_service.enable_mcp = schedule.enable_mcp + + characters_result = await db.execute( + select(Character).where(Character.project_id == project.id) + ) + characters = characters_result.scalars().all() + characters_info = self._build_characters_info(characters) or "暂无角色信息" + + chapter_count = max(schedule.outline_batch_size, 1) + template = await PromptService.get_template("OUTLINE_CREATE", schedule.user_id, db) + prompt = PromptService.format_prompt( + template, + title=project.title, + theme=project.theme or "未设定", + genre=project.genre or "通用", + chapter_count=chapter_count, + narrative_perspective=project.narrative_perspective or "第三人称", + time_period=project.world_time_period or "未设定", + location=project.world_location or "未设定", + atmosphere=project.world_atmosphere or "未设定", + rules=project.world_rules or "未设定", + characters_info=characters_info, + all_chapters_brief="暂无章节(首次生成)", + existing_characters=characters_info, + existing_organizations="暂无组织信息", + requirements="", + mcp_references="", + ) + + accumulated_text = "" + async for chunk in ai_service.generate_text_stream( + prompt=prompt, + provider=None, + model=schedule.model, + ): + accumulated_text += chunk + + cleaned_text = AIService._clean_json_response(accumulated_text) + parsed = json.loads(cleaned_text) + if isinstance(parsed, list): + outline_data = parsed + elif isinstance(parsed, dict): + outline_data = parsed.get("chapters") or parsed.get("data") or [parsed] + else: + outline_data = [] + + outline_data = [ + item for item in outline_data + if isinstance(item, dict) and (item.get("title") or item.get("summary") or item.get("content")) + ] + if not outline_data: + raise ValueError("自动生成首批大纲失败:AI 未返回有效大纲数组") + + return await self._save_outlines( + project_id=project.id, + outline_data=outline_data, + db=db, + start_index=1, + ) + + async def _continue_project_outlines( + self, + schedule: ProjectGenerationSchedule, + project: Project, + outlines: list[Outline], + db: AsyncSession, + ) -> list[Outline]: + """自动续写项目大纲。""" + if not outlines: + return [] + + ai_service = await self._build_user_ai_service(schedule.user_id, db) + ai_service.enable_mcp = schedule.enable_mcp + + characters_result = await db.execute( + select(Character).where(Character.project_id == project.id) + ) + characters = characters_result.scalars().all() + context = self._build_outline_continue_context(project, outlines, characters) + + # 构建补充上下文 + chapters_result = await db.execute( + select(Chapter).where(Chapter.project_id == project.id) + .order_by(Chapter.chapter_number.asc(), Chapter.sub_index.asc()) + ) + all_chapters = chapters_result.scalars().all() + all_chapters_brief = self._build_all_chapters_brief(all_chapters, outlines) + + org_characters = [c for c in characters if c.is_organization] + person_characters = [c for c in characters if not c.is_organization] + existing_organizations = "\n".join( + [f"- {c.name}: {c.personality[:80] if c.personality else '暂无'}" for c in org_characters] + ) if org_characters else "暂无组织信息" + existing_characters = self._build_characters_info(person_characters) or "暂无角色信息" + + chapter_count = max(schedule.outline_batch_size, 1) + last_chapter_number = outlines[-1].order_index or len(outlines) + start_chapter = last_chapter_number + 1 + end_chapter = start_chapter + chapter_count - 1 + + template = await PromptService.get_template("OUTLINE_CONTINUE", schedule.user_id, db) + prompt = PromptService.format_prompt( + template, + title=project.title, + theme=project.theme or "未设定", + genre=project.genre or "通用", + narrative_perspective=project.narrative_perspective or "第三人称", + time_period=project.world_time_period or "未设定", + location=project.world_location or "未设定", + atmosphere=project.world_atmosphere or "未设定", + rules=project.world_rules or "未设定", + recent_outlines=context["recent_outlines"], + characters_info=context["characters_info"], + all_chapters_brief=all_chapters_brief, + existing_characters=existing_characters, + existing_organizations=existing_organizations, + chapter_count=chapter_count, + start_chapter=start_chapter, + end_chapter=end_chapter, + current_chapter_count=len(outlines), + plot_stage_instruction="继续展开情节,深化角色关系,推进主线冲突", + plot_stage="development", + story_direction="自然延续", + requirements="", + mcp_references="", + ) + + accumulated_text = "" + async for chunk in ai_service.generate_text_stream( + prompt=prompt, + provider=None, + model=schedule.model, + ): + accumulated_text += chunk + + cleaned_text = AIService._clean_json_response(accumulated_text) + parsed = json.loads(cleaned_text) + if isinstance(parsed, list): + outline_data = parsed + elif isinstance(parsed, dict): + outline_data = parsed.get("chapters") or parsed.get("data") or [parsed] + else: + outline_data = [] + + outline_data = [ + item for item in outline_data + if isinstance(item, dict) and (item.get("title") or item.get("summary") or item.get("content")) + ] + if not outline_data: + raise ValueError("自动续写大纲失败:AI 未返回有效大纲数组") + + return await self._save_outlines( + project_id=project.id, + outline_data=outline_data, + db=db, + start_index=start_chapter, + ) + + async def _expand_one_to_many_outlines( + self, + schedule: ProjectGenerationSchedule, + project: Project, + outlines: list[Outline], + ai_service: AIService, + db: AsyncSession, + ) -> list[Chapter]: + """为 one-to-many 项目补齐未展开大纲对应的章节。""" + created_chapters: list[Chapter] = [] + expansion_service = PlotExpansionService(ai_service) + outlines_to_expand = outlines[:schedule.outline_batch_size] + + for outline in outlines_to_expand: + chapter_plans = await expansion_service.analyze_outline_for_chapters( + outline=outline, + project=project, + db=db, + target_chapter_count=schedule.chapters_per_outline, + expansion_strategy=schedule.expansion_strategy, + enable_scene_analysis=schedule.enable_scene_analysis, + model=schedule.model, + ) + created = await expansion_service.create_chapters_from_plans( + outline_id=outline.id, + chapter_plans=chapter_plans, + project_id=project.id, + db=db, + start_chapter_number=None, + ) + created_chapters.extend(created) + + created_chapters.sort(key=lambda chapter: (chapter.chapter_number, chapter.sub_index)) + return created_chapters + + async def _ensure_ready_chapters( + self, + schedule: ProjectGenerationSchedule, + project: Project, + state: dict[str, Any], + db: AsyncSession, + ) -> list[Chapter]: + """根据项目模式补齐可写章节。""" + pending_content_chapters = list(state["pending_content_chapters"]) + outlines = list(state["outlines"]) + chapters = list(state["chapters"]) + + if project.outline_mode == "one-to-one": + generated_initial_outlines = False + if not outlines: + new_outlines = await self._generate_initial_outlines( + schedule=schedule, + project=project, + db=db, + ) + outlines.extend(new_outlines) + generated_initial_outlines = bool(new_outlines) + + if ( + not generated_initial_outlines + and not state["missing_one_to_one_outlines"] + and not pending_content_chapters + ): + new_outlines = await self._continue_project_outlines( + schedule=schedule, + project=project, + outlines=outlines, + db=db, + ) + outlines.extend(new_outlines) + + if any(outline.order_index is not None for outline in outlines): + created_chapters = await self._create_missing_one_to_one_chapters( + project_id=project.id, + outlines=outlines, + chapters=chapters, + db=db, + ) + pending_content_chapters.extend(created_chapters) + + if project.outline_mode == "one-to-many" and len(pending_content_chapters) < schedule.min_ready_chapters: + if not outlines: + new_outlines = await self._generate_initial_outlines( + schedule=schedule, + project=project, + db=db, + ) + outlines.extend(new_outlines) + state["unexpanded_outlines"] = list(new_outlines) + elif not state["unexpanded_outlines"]: + new_outlines = await self._continue_project_outlines( + schedule=schedule, + project=project, + outlines=outlines, + db=db, + ) + outlines.extend(new_outlines) + state["unexpanded_outlines"] = list(new_outlines) + + if state["unexpanded_outlines"]: + ai_service = await self._build_user_ai_service(schedule.user_id, db) + ai_service.enable_mcp = schedule.enable_mcp + created_chapters = await self._expand_one_to_many_outlines( + schedule=schedule, + project=project, + outlines=state["unexpanded_outlines"], + ai_service=ai_service, + db=db, + ) + pending_content_chapters.extend(created_chapters) + + pending_content_chapters.sort(key=lambda chapter: (chapter.chapter_number, chapter.sub_index)) + return pending_content_chapters + + def _resolve_pipeline_stage( + self, + project: Project, + original_state: dict[str, Any], + pending_content_chapters: list[Chapter], + ) -> str: + """根据本次推进动作推断流水线阶段。""" + if project.outline_mode == "one-to-many": + original_pending = len(original_state["pending_content_chapters"]) + if len(pending_content_chapters) > original_pending: + return "expand" + + if project.outline_mode == "one-to-one": + original_pending = len(original_state["pending_content_chapters"]) + if len(pending_content_chapters) > original_pending: + return "chapter_create" + + return "content" + + def _select_chapters_for_generation( + self, + schedule: ProjectGenerationSchedule, + pending_content_chapters: list[Chapter], + ) -> list[Chapter]: + """选取本轮需要生成正文的章节。""" + return pending_content_chapters[:schedule.chapters_per_run] + + async def _prepare_generation_candidates( + self, + schedule: ProjectGenerationSchedule, + project: Project, + state: dict[str, Any], + db: AsyncSession, + ) -> list[Chapter]: + """补齐结构后返回可用于正文生成的章节列表。""" + return await self._ensure_ready_chapters( + schedule=schedule, + project=project, + state=state, + db=db, + ) + + def _has_expand_capacity( + self, + schedule: ProjectGenerationSchedule, + state: dict[str, Any], + ) -> bool: + """判断 one-to-many 项目是否需要继续展开。""" + return ( + bool(state["unexpanded_outlines"]) + and len(state["pending_content_chapters"]) < schedule.min_ready_chapters + ) + + def _resolve_no_pending_status( + self, + project: Project, + state: dict[str, Any], + ) -> tuple[str, Optional[str]]: + """在无可写章节时给出状态和错误信息。""" + if project.outline_mode == "one-to-many" and state["unexpanded_outlines"]: + return "failed_expand", "存在待展开大纲,但未成功生成章节" + return "skipped_no_pending", None + + async def _run_content_stage( + self, + schedule: ProjectGenerationSchedule, + chapters_to_generate: list[Chapter], + db: AsyncSession, + ) -> str: + """创建正文批量任务并返回任务 ID。""" + batch_task = await self._create_scheduled_batch_task( + schedule=schedule, + chapters_to_generate=chapters_to_generate, + db=db, + ) + return batch_task.id + + def _build_default_settings_payload(self) -> dict[str, Any]: + """构建默认 AI 设置。""" + provider = config_settings.default_ai_provider + if provider == "anthropic": + api_key = config_settings.anthropic_api_key or config_settings.openai_api_key or "" + api_base_url = ( + config_settings.anthropic_base_url or config_settings.openai_base_url or "" + ) + else: + api_key = config_settings.openai_api_key or config_settings.anthropic_api_key or "" + api_base_url = ( + config_settings.openai_base_url or config_settings.anthropic_base_url or "" + ) + + return { + "api_provider": provider, + "api_key": api_key, + "api_base_url": api_base_url, + "llm_model": config_settings.default_model, + "temperature": config_settings.default_temperature, + "max_tokens": config_settings.default_max_tokens, + } + + async def _build_user_ai_service( + self, + user_id: str, + db: AsyncSession, + ) -> AIService: + """为后台调度任务构建 AI 服务实例。""" + settings_result = await db.execute( + select(Settings).where(Settings.user_id == user_id) + ) + user_settings = settings_result.scalar_one_or_none() + + if user_settings is None: + user_settings = Settings(user_id=user_id, **self._build_default_settings_payload()) + db.add(user_settings) + await db.commit() + await db.refresh(user_settings) + + mcp_result = await db.execute( + select(MCPPlugin).where(MCPPlugin.user_id == user_id) + ) + mcp_plugins = mcp_result.scalars().all() + enable_mcp = any(plugin.enabled for plugin in mcp_plugins) if mcp_plugins else False + + return create_user_ai_service_with_mcp( + api_provider=user_settings.api_provider, + api_key=user_settings.api_key, + api_base_url=user_settings.api_base_url or "", + model_name=user_settings.llm_model, + temperature=user_settings.temperature, + max_tokens=user_settings.max_tokens, + user_id=user_id, + db_session=db, + system_prompt=user_settings.system_prompt, + enable_mcp=enable_mcp, + ) + + async def _execute_scheduled_batch_task(self, batch_id: str, user_id: str) -> None: + """异步执行调度器创建的正文批量任务。""" + db_session: Optional[AsyncSession] = None + + try: + engine = await get_engine(user_id) + session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + ) + db_session = session_factory() + ai_service = await self._build_user_ai_service(user_id, db_session) + await execute_batch_generation_in_order( + batch_id=batch_id, + user_id=user_id, + ai_service=ai_service, + ) + except Exception as exc: + logger.error( + "自动推进正文任务执行失败: batch_id=%s error=%s", + batch_id, + exc, + exc_info=True, + ) + finally: + if db_session is not None: + await db_session.close() + + async def run_project_automation( + self, + schedule_id: str, + db: AsyncSession, + ) -> ProjectGenerationSchedule | None: + """执行一次项目自动推进。""" + schedule = await self._get_schedule(schedule_id, db) + if schedule is None: + return None + + launched_batch_id: Optional[str] = None + schedule.last_triggered_at = datetime.utcnow() + schedule.last_error = None + + try: + state = await self.collect_project_generation_state(schedule, db) + project: Project = state["project"] + active_batch_task: BatchGenerationTask | None = state["active_batch_task"] + + if active_batch_task is not None: + schedule.last_pipeline_stage = "content" + schedule.last_run_status = "skipped_conflict" + schedule.last_error = None + schedule.current_batch_task_id = active_batch_task.id + else: + pending_content_chapters = await self._prepare_generation_candidates( + schedule=schedule, + project=project, + state=state, + db=db, + ) + + if not pending_content_chapters: + status, error_message = self._resolve_no_pending_status(project, state) + schedule.last_pipeline_stage = "expand" if status == "failed_expand" else "skip" + schedule.last_run_status = status + schedule.last_error = error_message + schedule.current_batch_task_id = None + else: + chapters_to_generate = self._select_chapters_for_generation( + schedule, + pending_content_chapters, + ) + can_generate, error_msg, _ = await check_prerequisites( + db, + chapters_to_generate[0], + ) + + if not can_generate: + schedule.last_pipeline_stage = "content" + schedule.last_run_status = "failed_content" + schedule.last_error = error_msg + schedule.current_batch_task_id = None + else: + launched_batch_id = await self._run_content_stage( + schedule=schedule, + chapters_to_generate=chapters_to_generate, + db=db, + ) + schedule.last_pipeline_stage = self._resolve_pipeline_stage( + project, + state, + pending_content_chapters, + ) + schedule.last_run_status = "success" + schedule.last_error = None + schedule.current_batch_task_id = launched_batch_id + + schedule.next_run_at = self.calculate_next_run_at( + schedule.cron_expr, + schedule.timezone, + ) + schedule.last_finished_at = datetime.utcnow() + await db.commit() + await db.refresh(schedule) + except Exception as exc: + logger.error( + "项目自动推进执行失败: schedule_id=%s error=%s", + schedule_id, + exc, + exc_info=True, + ) + await db.rollback() + + schedule = await self._get_schedule(schedule_id, db) + if schedule is None: + return None + + schedule.last_pipeline_stage = schedule.last_pipeline_stage or "content" + schedule.last_run_status = "failed_content" + schedule.last_error = str(exc) + schedule.current_batch_task_id = None + try: + schedule.next_run_at = self.calculate_next_run_at( + schedule.cron_expr, + schedule.timezone, + ) + except ValueError: + # Cron 表达式也出错时,延迟 1 小时后重试,避免永远卡住 + schedule.next_run_at = datetime.utcnow() + timedelta(hours=1) + logger.warning( + "自动推进错误处理中 Cron 解析也失败,延迟 1 小时重试: schedule_id=%s", + schedule_id, + ) + schedule.last_finished_at = datetime.utcnow() + await db.commit() + await db.refresh(schedule) + return schedule + + if launched_batch_id is not None: + asyncio.create_task( + self._execute_scheduled_batch_task(launched_batch_id, schedule.user_id), + name=f"project-automation-{launched_batch_id}", + ) + logger.info( + "项目自动推进已创建正文任务: schedule_id=%s batch_id=%s", + schedule.id, + launched_batch_id, + ) + else: + logger.info( + "项目自动推进执行完成: schedule_id=%s status=%s", + schedule.id, + schedule.last_run_status, + ) + + return schedule + + +project_generation_automation_service = ProjectGenerationAutomationService() diff --git a/backend/app/services/project_generation_scheduler.py b/backend/app/services/project_generation_scheduler.py new file mode 100644 index 00000000..56b7d880 --- /dev/null +++ b/backend/app/services/project_generation_scheduler.py @@ -0,0 +1,113 @@ +"""项目自动推进调度器。""" +from __future__ import annotations + +import asyncio +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.config import settings as config_settings +from app.database import get_engine +from app.logger import get_logger +from app.models.project_generation_schedule import ProjectGenerationSchedule +from app.services.project_generation_automation_service import project_generation_automation_service + +logger = get_logger(__name__) + +_scheduler_task: asyncio.Task | None = None + + +async def _create_session() -> AsyncSession: + """创建调度器使用的数据库会话。""" + engine = await get_engine("system") + session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + return session_factory() + + +async def _scheduler_loop(interval_seconds: int = 30) -> None: + """后台轮询到期的项目自动推进计划。 + + PostgreSQL 环境使用 SELECT ... FOR UPDATE SKIP LOCKED 防止多实例重复触发。 + SQLite 环境退化为普通查询(单实例部署场景)。 + """ + is_postgres = "postgresql" in config_settings.database_url.lower() + + while True: + try: + async with await _create_session() as db: + now = datetime.utcnow() + query = ( + select(ProjectGenerationSchedule) + .where( + ProjectGenerationSchedule.enabled.is_(True), + ProjectGenerationSchedule.next_run_at.is_not(None), + ProjectGenerationSchedule.next_run_at <= now, + ) + .order_by(ProjectGenerationSchedule.next_run_at.asc()) + ) + if is_postgres: + query = query.with_for_update(skip_locked=True) + + if is_postgres: + async with db.begin(): + result = await db.execute(query) + schedules = result.scalars().all() + for schedule in schedules: + try: + await project_generation_automation_service.run_project_automation( + schedule.id, db + ) + except Exception as exc: + logger.error( + "项目自动推进执行失败: schedule_id=%s error=%s", + schedule.id, + exc, + exc_info=True, + ) + else: + # SQLite:不做行锁,直接查询执行 + result = await db.execute(query) + schedules = result.scalars().all() + for schedule in schedules: + try: + await project_generation_automation_service.run_project_automation( + schedule.id, db + ) + except Exception as exc: + logger.error( + "项目自动推进执行失败: schedule_id=%s error=%s", + schedule.id, + exc, + exc_info=True, + ) + + await asyncio.sleep(interval_seconds) + except asyncio.CancelledError: + logger.info("项目自动推进调度器已停止") + raise + except Exception as exc: + logger.error("项目自动推进调度器异常: %s", exc, exc_info=True) + await asyncio.sleep(interval_seconds) + + +def start_project_generation_scheduler() -> None: + """启动项目自动推进调度器。""" + global _scheduler_task + if _scheduler_task is not None and not _scheduler_task.done(): + return + _scheduler_task = asyncio.create_task(_scheduler_loop()) + logger.info("项目自动推进调度器已启动") + + +async def stop_project_generation_scheduler() -> None: + """停止项目自动推进调度器。""" + global _scheduler_task + if _scheduler_task is None: + return + _scheduler_task.cancel() + try: + await _scheduler_task + except asyncio.CancelledError: + pass + _scheduler_task = None diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index f965b270..315a434f 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -2604,19 +2604,13 @@ async def build_novel_cover_prompt( @staticmethod def format_prompt(template: str, **kwargs) -> str: """ - 格式化提示词模板 - - Args: - template: 提示词模板 - **kwargs: 模板参数 - - Returns: - 格式化后的提示词 + 格式化提示词模板。 + + 缺失的参数自动以空字符串替代,避免用户自定义模板因新增占位符而崩溃。 """ - try: - return template.format(**kwargs) - except KeyError as e: - raise ValueError(f"缺少必需的参数: {e}") + from collections import defaultdict + safe_kwargs = defaultdict(str, kwargs) + return template.format_map(safe_kwargs) @classmethod diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 042e0ec5..f8b043d6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,7 +15,6 @@ import ChapterReader from './pages/ChapterReader'; import ChapterAnalysis from './pages/ChapterAnalysis'; import Foreshadows from './pages/Foreshadows'; import WritingStyles from './pages/WritingStyles'; -import PromptWorkshop from './pages/PromptWorkshop'; import Settings from './pages/Settings'; import MCPPlugins from './pages/MCPPlugins'; import UserManagement from './pages/UserManagement'; diff --git a/frontend/src/components/AutoAdvancementSettings.tsx b/frontend/src/components/AutoAdvancementSettings.tsx new file mode 100644 index 00000000..cbd737ab --- /dev/null +++ b/frontend/src/components/AutoAdvancementSettings.tsx @@ -0,0 +1,514 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Alert, + Button, + Card, + Col, + Collapse, + Form, + InputNumber, + Popconfirm, + Row, + Select, + Space, + Spin, + Switch, + Tag, + Typography, + message, +} from 'antd'; +import { + CheckCircleOutlined, + ClockCircleOutlined, + DeleteOutlined, + ExclamationCircleOutlined, + PlayCircleOutlined, + ReloadOutlined, + SaveOutlined, + SettingOutlined, + ThunderboltOutlined, +} from '@ant-design/icons'; +import { projectApi, projectAutomationApi, settingsApi } from '../services/api'; +import type { Project, ProjectGenerationSchedule, ProjectGenerationScheduleUpdate } from '../types'; +import { buildCronExpression, parseCronExpression, WEEKDAY_OPTIONS, HOUR_OPTIONS, MINUTE_OPTIONS } from '../utils/cronUtils'; +import type { CronFrequency, CronMode } from '../utils/cronUtils'; + +const { Text } = Typography; + +const defaultValues: ProjectGenerationScheduleUpdate = { + enabled: false, + cron_expr: '0 9 * * *', + timezone: 'Asia/Shanghai', + chapters_per_run: 1, + target_word_count: 3000, + enable_analysis: false, + enable_mcp: false, + max_retries: 3, + model: '', + min_ready_chapters: 3, + outline_batch_size: 1, + chapters_per_outline: 3, + expansion_strategy: 'balanced', + enable_scene_analysis: false, +}; + +function normalizeExpansionStrategy(value?: string | null): 'balanced' | 'climax' | 'detail' { + if (value === 'climax' || value === 'detail') return value; + return 'balanced'; +} + +function getRunStatusMeta(status?: string) { + switch (status) { + case 'success': return { color: 'green', icon: , text: '成功' }; + case 'skipped_conflict': return { color: 'orange', icon: , text: '跳过(任务冲突)' }; + case 'skipped_no_pending': return { color: 'default', icon: , text: '跳过(无待写章节)' }; + case 'failed_expand': return { color: 'red', icon: , text: '展开失败' }; + case 'failed_content': return { color: 'red', icon: , text: '生成失败' }; + default: return { color: 'default', icon: , text: status || '未执行' }; + } +} + +function getPipelineStageLabel(stage?: string) { + switch (stage) { + case 'expand': return '大纲展开'; + case 'chapter_create': return '章节创建'; + case 'content': return '正文生成'; + case 'skip': return '跳过'; + default: return stage || '-'; + } +} + +function formatDateTime(value?: string | null): string { + if (!value) return '-'; + const utcValue = value.endsWith('Z') ? value : value + 'Z'; + return new Date(utcValue).toLocaleString(); +} + +interface ModelOption { value: string; label: string; description: string; } + +export default function AutoAdvancementSettings() { + const [form] = Form.useForm(); + const [projects, setProjects] = useState([]); + const [selectedProjectId, setSelectedProjectId] = useState(); + const [schedule, setSchedule] = useState(null); + const [loaded, setLoaded] = useState(false); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [triggering, setTriggering] = useState(false); + const [cronMode, setCronMode] = useState('visual'); + const [cronFrequency, setCronFrequency] = useState('daily'); + const [cronHour, setCronHour] = useState(9); + const [cronMinute, setCronMinute] = useState(0); + const [cronWeekday, setCronWeekday] = useState('1'); + const [modelOptions, setModelOptions] = useState([]); + const [modelLoading, setModelLoading] = useState(false); + const abortRef = useRef(null); + + // 加载项目列表 + useEffect(() => { + const loadProjects = async () => { + try { + const data = await projectApi.getProjects(); + const list = Array.isArray(data) ? data : (data as any).items || []; + setProjects(list); + if (list.length > 0 && !selectedProjectId) { + setSelectedProjectId(list[0].id); + } + } catch (err) { + console.error('加载项目列表失败:', err); + } + }; + void loadProjects(); + }, []); + + // 加载自动推进配置 + useEffect(() => { + if (!selectedProjectId) return; + if (abortRef.current) abortRef.current.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + const load = async () => { + setLoaded(false); + setLoading(true); + try { + const data = await projectAutomationApi.getSchedule(selectedProjectId, controller.signal); + if (controller.signal.aborted) return; + + const project = projects.find((p) => p.id === selectedProjectId); + if (data) { + const merged = { ...defaultValues, ...data, model: data.model || '', expansion_strategy: normalizeExpansionStrategy(data.expansion_strategy) }; + form.setFieldsValue(merged); + setSchedule({ ...merged, outline_mode: data.outline_mode || project?.outline_mode } as ProjectGenerationSchedule); + // 解析 cron + const parsed = parseCronExpression(data.cron_expr); + setCronMode(parsed.mode); + setCronFrequency(parsed.frequency); + setCronHour(parsed.hour); + setCronMinute(parsed.minute); + setCronWeekday(parsed.weekday); + } else { + form.setFieldsValue(defaultValues); + setSchedule(null); + setCronMode('visual'); + setCronFrequency('daily'); + setCronHour(9); + setCronMinute(0); + } + } catch (err: any) { + if (err?.name === 'CanceledError' || err?.code === 'ERR_CANCELED') return; + console.error('加载自动推进配置失败:', err); + } finally { + setLoading(false); + setLoaded(true); + } + }; + void load(); + }, [selectedProjectId]); + + const updateVisualCron = (next: Partial<{ frequency: CronFrequency; hour: number; minute: number; weekday: string }>) => { + const freq = next.frequency ?? cronFrequency; + const hour = next.hour ?? cronHour; + const minute = next.minute ?? cronMinute; + const weekday = next.weekday ?? cronWeekday; + setCronFrequency(freq); + if (next.hour !== undefined) setCronHour(hour); + if (next.minute !== undefined) setCronMinute(minute); + if (next.weekday !== undefined) setCronWeekday(weekday); + form.setFieldValue('cron_expr', buildCronExpression(freq, hour, minute, weekday)); + }; + + const handleSave = async (values: ProjectGenerationScheduleUpdate) => { + if (!selectedProjectId) return; + setSaving(true); + try { + const cronExpr = cronMode === 'advanced' ? values.cron_expr : buildCronExpression(cronFrequency, cronHour, cronMinute, cronWeekday); + const payload: ProjectGenerationScheduleUpdate = { + ...values, + cron_expr: cronExpr, + model: (values.model || '').trim() || undefined, + expansion_strategy: normalizeExpansionStrategy(values.expansion_strategy), + }; + const saved = await projectAutomationApi.saveSchedule(selectedProjectId, payload); + setSchedule(saved); + message.success('自动推进配置已保存'); + } catch (err) { + console.error('保存失败:', err); + message.error('保存自动推进配置失败'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!selectedProjectId) return; + try { + await projectAutomationApi.deleteSchedule(selectedProjectId); + setSchedule(null); + form.setFieldsValue(defaultValues); + message.success('自动推进配置已删除'); + } catch (err) { + message.error('删除失败'); + } + }; + + const handleTrigger = async () => { + if (!selectedProjectId) return; + setTriggering(true); + try { + const updated = await projectAutomationApi.triggerSchedule(selectedProjectId); + setSchedule(updated); + message.success('已触发一次自动推进'); + } catch (err) { + message.error('触发失败'); + } finally { + setTriggering(false); + } + }; + + const handleModelSearch = async () => { + setModelLoading(true); + try { + const settings = await settingsApi.getSettings(); + if (!settings?.api_key || !settings?.api_provider) { + message.warning('请先在普通设置中配置 API 密钥和提供商'); + return; + } + const result = await settingsApi.getAvailableModels({ + api_key: settings.api_key, + api_base_url: settings.api_base_url, + provider: settings.api_provider, + }); + setModelOptions(result.models || []); + } catch (err) { + message.error('获取模型列表失败'); + } finally { + setModelLoading(false); + } + }; + + const statusMeta = getRunStatusMeta(schedule?.last_run_status); + + return ( + + + + {/* 项目选择 */} + + + 选择项目 + { + setCronMode(v); + if (v === 'visual') { + form.setFieldValue('cron_expr', buildCronExpression(cronFrequency, cronHour, cronMinute, cronWeekday)); + } + }} + options={[{ value: 'visual', label: '可视化' }, { value: 'advanced', label: '高级(Cron)' }]} + /> + + + + + updateVisualCron({ frequency: v })} options={[ + { value: 'daily', label: '每天' }, + { value: 'weekdays', label: '工作日' }, + { value: 'weekly', label: '每周' }, + { value: 'every_30_minutes', label: '每30分钟' }, + ]} /> + + + {cronFrequency === 'weekly' && ( + + + updateVisualCron({ hour: v })} options={HOUR_OPTIONS} /> + + + + + + + )} + + + 生成策略与模型} bordered={false} style={{ borderRadius: 16, marginBottom: 16 }}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }]} + /> + + + + + + + + + + + + + + {/* 右侧状态面板 */} + + + {schedule ? ( + +
+ 大纲模式 +
{schedule.outline_mode || '未设置'} +
+
+ 最近执行状态 +
{statusMeta.text}
+
+
+ 流水线阶段 +
{getPipelineStageLabel(schedule.last_pipeline_stage)}
+
+
+ 最近触发时间 +
{formatDateTime(schedule.last_triggered_at)}
+
+
+ 最近完成时间 +
{formatDateTime(schedule.last_finished_at)}
+
+
+ 下次运行时间 +
{formatDateTime(schedule.next_run_at)}
+
+ {schedule.current_batch_task_id && ( +
+ 当前任务 ID +
{schedule.current_batch_task_id}
+
+ )} + {schedule.last_error && ( + + )} + + ) : ( + {loaded ? '尚未配置自动推进' : '加载中...'} + )} + + + + + + + + + + + ); +} diff --git a/frontend/src/pages/SystemSettings.tsx b/frontend/src/pages/SystemSettings.tsx index 1fed6822..3578bddb 100644 --- a/frontend/src/pages/SystemSettings.tsx +++ b/frontend/src/pages/SystemSettings.tsx @@ -6,6 +6,7 @@ import { BellOutlined, CheckCircleOutlined, DeleteOutlined, EditOutlined, EyeInv import { announcementApi, authApi, settingsApi } from '../services/api'; import type { Announcement, AnnouncementCreate, AnnouncementLevel, AnnouncementStatus, AnnouncementStatusResponse, AnnouncementUpdate, SystemSMTPSettings, SystemSMTPSettingsUpdate, User } from '../types'; import MarkdownRenderer from '../components/MarkdownRenderer'; +import AutoAdvancementSettings from '../components/AutoAdvancementSettings'; const { Title, Text, Paragraph } = Typography; const { Option } = Select; @@ -740,6 +741,16 @@ export default function SystemSettingsPage() { ), }, + { + key: 'automation', + label: ( + + + 自动推进 + + ), + children: , + }, ]} />
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ae9a3774..5b6cfb72 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1304,4 +1304,29 @@ export const foreshadowApi = { `/foreshadows/projects/${projectId}/sync-from-analysis`, data ), +}; + +export const projectAutomationApi = { + getSchedule: async (projectId: string, signal?: AbortSignal): Promise => { + const response = await axios.get(`/api/project-automation/${projectId}`, { + withCredentials: true, + validateStatus: (status) => status === 200 || status === 404, + signal, + }); + + if (response.status === 404) { + return null; + } + + return response.data; + }, + + saveSchedule: (projectId: string, data: import('../types').ProjectGenerationScheduleUpdate) => + api.put(`/project-automation/${projectId}`, data), + + deleteSchedule: (projectId: string) => + api.delete(`/project-automation/${projectId}`), + + triggerSchedule: (projectId: string) => + api.post(`/project-automation/${projectId}/trigger`), }; \ No newline at end of file diff --git a/frontend/src/utils/cronUtils.ts b/frontend/src/utils/cronUtils.ts new file mode 100644 index 00000000..1558517f --- /dev/null +++ b/frontend/src/utils/cronUtils.ts @@ -0,0 +1,130 @@ +/** Cron 表达式工具函数 */ + +export type CronFrequency = 'daily' | 'weekdays' | 'weekly' | 'every_30_minutes'; +export type CronMode = 'visual' | 'advanced'; + +export const WEEKDAY_OPTIONS = [ + { value: '1', label: '周一' }, + { value: '2', label: '周二' }, + { value: '3', label: '周三' }, + { value: '4', label: '周四' }, + { value: '5', label: '周五' }, + { value: '6', label: '周六' }, + { value: '0', label: '周日' }, +]; + +export const HOUR_OPTIONS = Array.from({ length: 24 }, (_, index) => ({ + value: index, + label: `${String(index).padStart(2, '0')} 时`, +})); + +export const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, index) => ({ + value: index, + label: `${String(index).padStart(2, '0')} 分`, +})); + +export interface CronParseResult { + mode: CronMode; + frequency: CronFrequency; + hour: number; + minute: number; + weekday: string; + cronExpr: string; +} + +/** 从可视化参数生成 Cron 表达式 */ +export function buildCronExpression( + frequency: CronFrequency, + hour: number, + minute: number, + weekday: string, +): string { + if (frequency === 'every_30_minutes') { + return '*/30 * * * *'; + } + if (frequency === 'weekdays') { + return `${minute} ${hour} * * 1-5`; + } + if (frequency === 'weekly') { + return `${minute} ${hour} * * ${weekday}`; + } + return `${minute} ${hour} * * *`; +} + +/** 从 Cron 表达式解析为可视化参数 */ +export function parseCronExpression(cronExpr?: string | null): CronParseResult { + const fallback: CronParseResult = { + mode: 'visual', + frequency: 'daily', + hour: 9, + minute: 0, + weekday: '1', + cronExpr: '0 9 * * *', + }; + + const rawExpr = (cronExpr || fallback.cronExpr).trim(); + const parts = rawExpr.split(/\s+/); + if (parts.length !== 5) { + return { ...fallback, mode: 'advanced', cronExpr: rawExpr || fallback.cronExpr }; + } + + const [minutePart, hourPart, dayOfMonth, month, dayOfWeek] = parts; + + if ( + (minutePart === '*/30' || minutePart === '0,30') && + hourPart === '*' && + dayOfMonth === '*' && + month === '*' && + dayOfWeek === '*' + ) { + return { + mode: 'visual', + frequency: 'every_30_minutes', + hour: 0, + minute: 0, + weekday: '1', + cronExpr: '*/30 * * * *', + }; + } + + const minute = Number(minutePart); + const hour = Number(hourPart); + + if ( + Number.isNaN(minute) || + Number.isNaN(hour) || + minute < 0 || + minute > 59 || + hour < 0 || + hour > 23 || + dayOfMonth !== '*' || + month !== '*' + ) { + return { ...fallback, mode: 'advanced', cronExpr: rawExpr }; + } + + if (dayOfWeek === '*') { + return { mode: 'visual', frequency: 'daily', hour, minute, weekday: '1', cronExpr: rawExpr }; + } + + if (dayOfWeek === '1-5') { + return { mode: 'visual', frequency: 'weekdays', hour, minute, weekday: '1', cronExpr: rawExpr }; + } + + if ( + WEEKDAY_OPTIONS.some( + (option) => option.value === dayOfWeek || (dayOfWeek === '7' && option.value === '0'), + ) + ) { + return { + mode: 'visual', + frequency: 'weekly', + hour, + minute, + weekday: dayOfWeek === '7' ? '0' : dayOfWeek, + cronExpr: rawExpr, + }; + } + + return { ...fallback, mode: 'advanced', cronExpr: rawExpr }; +}