feature: 新增小说封面图片生成功能
This commit is contained in:
@@ -79,6 +79,7 @@ backend/data/*.db-shm
|
|||||||
backend/data/*.db-wal
|
backend/data/*.db-wal
|
||||||
backend/data/users.json
|
backend/data/users.json
|
||||||
backend/data/admins.json
|
backend/data/admins.json
|
||||||
|
backend/storage/
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
*.bak
|
*.bak
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""添加小说封面生成配置
|
||||||
|
|
||||||
|
Revision ID: 8e3ac0236b27
|
||||||
|
Revises: d4d253e3f4c6
|
||||||
|
Create Date: 2026-03-16 10:56:31.489936
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '8e3ac0236b27'
|
||||||
|
down_revision: Union[str, None] = 'd4d253e3f4c6'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('projects', sa.Column('cover_image_url', sa.String(length=1000), nullable=True, comment='封面图片访问地址'))
|
||||||
|
op.add_column('projects', sa.Column('cover_prompt', sa.Text(), nullable=True, comment='最近一次生成封面使用的提示词'))
|
||||||
|
op.add_column('projects', sa.Column('cover_status', sa.String(length=20), nullable=False, server_default='none', comment='封面状态: none/generating/ready/failed'))
|
||||||
|
op.add_column('projects', sa.Column('cover_error', sa.Text(), nullable=True, comment='最近一次封面生成失败原因'))
|
||||||
|
op.add_column('projects', sa.Column('cover_updated_at', sa.DateTime(), nullable=True, comment='最近一次封面生成成功时间'))
|
||||||
|
op.add_column('settings', sa.Column('cover_api_provider', sa.String(length=50), nullable=True, comment='封面图片API提供商'))
|
||||||
|
op.add_column('settings', sa.Column('cover_api_key', sa.String(length=500), nullable=True, comment='封面图片API密钥'))
|
||||||
|
op.add_column('settings', sa.Column('cover_api_base_url', sa.String(length=500), nullable=True, comment='封面图片自定义API地址'))
|
||||||
|
op.add_column('settings', sa.Column('cover_image_model', sa.String(length=100), nullable=True, comment='封面图片模型名称'))
|
||||||
|
op.add_column('settings', sa.Column('cover_enabled', sa.Boolean(), nullable=False, server_default=sa.text('false'), comment='是否启用封面图片生成'))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('settings', 'cover_enabled')
|
||||||
|
op.drop_column('settings', 'cover_image_model')
|
||||||
|
op.drop_column('settings', 'cover_api_base_url')
|
||||||
|
op.drop_column('settings', 'cover_api_key')
|
||||||
|
op.drop_column('settings', 'cover_api_provider')
|
||||||
|
op.drop_column('projects', 'cover_updated_at')
|
||||||
|
op.drop_column('projects', 'cover_error')
|
||||||
|
op.drop_column('projects', 'cover_status')
|
||||||
|
op.drop_column('projects', 'cover_prompt')
|
||||||
|
op.drop_column('projects', 'cover_image_url')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""添加小说封面生成配置
|
||||||
|
|
||||||
|
Revision ID: 17ce752ed7cc
|
||||||
|
Revises: d887fd1a30a6
|
||||||
|
Create Date: 2026-03-16 10:58:55.143700
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '17ce752ed7cc'
|
||||||
|
down_revision: Union[str, None] = 'd887fd1a30a6'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('projects', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('cover_image_url', sa.String(length=1000), nullable=True, comment='封面图片访问地址'))
|
||||||
|
batch_op.add_column(sa.Column('cover_prompt', sa.Text(), nullable=True, comment='最近一次生成封面使用的提示词'))
|
||||||
|
batch_op.add_column(sa.Column('cover_status', sa.String(length=20), nullable=False, server_default='none', comment='封面状态: none/generating/ready/failed'))
|
||||||
|
batch_op.add_column(sa.Column('cover_error', sa.Text(), nullable=True, comment='最近一次封面生成失败原因'))
|
||||||
|
batch_op.add_column(sa.Column('cover_updated_at', sa.DateTime(), nullable=True, comment='最近一次封面生成成功时间'))
|
||||||
|
|
||||||
|
with op.batch_alter_table('settings', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('cover_api_provider', sa.String(length=50), nullable=True, comment='封面图片API提供商'))
|
||||||
|
batch_op.add_column(sa.Column('cover_api_key', sa.String(length=500), nullable=True, comment='封面图片API密钥'))
|
||||||
|
batch_op.add_column(sa.Column('cover_api_base_url', sa.String(length=500), nullable=True, comment='封面图片自定义API地址'))
|
||||||
|
batch_op.add_column(sa.Column('cover_image_model', sa.String(length=100), nullable=True, comment='封面图片模型名称'))
|
||||||
|
batch_op.add_column(sa.Column('cover_enabled', sa.Boolean(), nullable=False, server_default=sa.text('0'), comment='是否启用封面图片生成'))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('settings', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('cover_enabled')
|
||||||
|
batch_op.drop_column('cover_image_model')
|
||||||
|
batch_op.drop_column('cover_api_base_url')
|
||||||
|
batch_op.drop_column('cover_api_key')
|
||||||
|
batch_op.drop_column('cover_api_provider')
|
||||||
|
|
||||||
|
with op.batch_alter_table('projects', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('cover_updated_at')
|
||||||
|
batch_op.drop_column('cover_error')
|
||||||
|
batch_op.drop_column('cover_status')
|
||||||
|
batch_op.drop_column('cover_prompt')
|
||||||
|
batch_op.drop_column('cover_image_url')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""项目封面生成与下载 API"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.cover_generation_service import cover_generation_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects", tags=["项目封面"])
|
||||||
|
|
||||||
|
|
||||||
|
class CoverGenerateRequest(BaseModel):
|
||||||
|
overwrite: bool = Field(default=True, description="是否覆盖已有封面")
|
||||||
|
|
||||||
|
|
||||||
|
class CoverGenerateResponse(BaseModel):
|
||||||
|
project_id: str
|
||||||
|
cover_status: str
|
||||||
|
cover_image_url: str | None = None
|
||||||
|
cover_prompt: str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/cover/generate", response_model=CoverGenerateResponse, summary="生成项目封面")
|
||||||
|
async def generate_project_cover(
|
||||||
|
project_id: str,
|
||||||
|
payload: CoverGenerateRequest,
|
||||||
|
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="未登录")
|
||||||
|
|
||||||
|
result = await cover_generation_service.generate_cover(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
overwrite=payload.overwrite,
|
||||||
|
)
|
||||||
|
return CoverGenerateResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/cover/download", summary="下载项目封面")
|
||||||
|
async def download_project_cover(
|
||||||
|
project_id: str,
|
||||||
|
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="未登录")
|
||||||
|
|
||||||
|
project, file_path = await cover_generation_service.get_cover_download_path(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
suffix = file_path.suffix or ".png"
|
||||||
|
filename = f"{project.title}-cover{suffix}"
|
||||||
|
return FileResponse(path=file_path, filename=filename, media_type="application/octet-stream")
|
||||||
@@ -14,6 +14,7 @@ import time
|
|||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.settings import Settings
|
from app.models.settings import Settings
|
||||||
|
from app.services.cover_generation_service import cover_generation_service
|
||||||
from app.schemas.settings import (
|
from app.schemas.settings import (
|
||||||
SettingsCreate, SettingsUpdate, SettingsResponse,
|
SettingsCreate, SettingsUpdate, SettingsResponse,
|
||||||
APIKeyPreset, APIKeyPresetConfig, PresetCreateRequest,
|
APIKeyPreset, APIKeyPresetConfig, PresetCreateRequest,
|
||||||
@@ -29,6 +30,13 @@ logger = get_logger(__name__)
|
|||||||
router = APIRouter(prefix="/settings", tags=["设置管理"])
|
router = APIRouter(prefix="/settings", tags=["设置管理"])
|
||||||
|
|
||||||
|
|
||||||
|
class CoverSettingsTestRequest(BaseModel):
|
||||||
|
cover_api_provider: str
|
||||||
|
cover_api_key: str
|
||||||
|
cover_api_base_url: Optional[str] = None
|
||||||
|
cover_image_model: str
|
||||||
|
|
||||||
|
|
||||||
def read_env_defaults() -> Dict[str, Any]:
|
def read_env_defaults() -> Dict[str, Any]:
|
||||||
"""从.env文件读取默认配置(仅读取,不修改)"""
|
"""从.env文件读取默认配置(仅读取,不修改)"""
|
||||||
return {
|
return {
|
||||||
@@ -142,6 +150,25 @@ async def get_settings(
|
|||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cover/test")
|
||||||
|
async def test_cover_settings(
|
||||||
|
data: CoverSettingsTestRequest,
|
||||||
|
user: User = Depends(require_login),
|
||||||
|
):
|
||||||
|
result = await cover_generation_service.test_cover_settings(
|
||||||
|
provider=data.cover_api_provider,
|
||||||
|
api_key=data.cover_api_key,
|
||||||
|
api_base_url=data.cover_api_base_url,
|
||||||
|
model=data.cover_image_model,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": result.success,
|
||||||
|
"message": result.message,
|
||||||
|
"provider": result.provider,
|
||||||
|
"model": result.model,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=SettingsResponse)
|
@router.post("", response_model=SettingsResponse)
|
||||||
async def save_settings(
|
async def save_settings(
|
||||||
data: SettingsCreate,
|
data: SettingsCreate,
|
||||||
|
|||||||
+7
-1
@@ -130,7 +130,8 @@ 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, book_import
|
changelog, careers, foreshadows, prompt_workshop, book_import,
|
||||||
|
project_covers
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth.router, prefix="/api")
|
app.include_router(auth.router, prefix="/api")
|
||||||
@@ -139,6 +140,7 @@ app.include_router(settings.router, prefix="/api")
|
|||||||
app.include_router(admin.router, prefix="/api")
|
app.include_router(admin.router, prefix="/api")
|
||||||
|
|
||||||
app.include_router(projects.router, prefix="/api")
|
app.include_router(projects.router, prefix="/api")
|
||||||
|
app.include_router(project_covers.router, prefix="/api")
|
||||||
app.include_router(wizard_stream.router, prefix="/api")
|
app.include_router(wizard_stream.router, prefix="/api")
|
||||||
app.include_router(inspiration.router, prefix="/api")
|
app.include_router(inspiration.router, prefix="/api")
|
||||||
app.include_router(outlines.router, prefix="/api")
|
app.include_router(outlines.router, prefix="/api")
|
||||||
@@ -157,8 +159,12 @@ app.include_router(prompt_workshop.router, prefix="/api") # 提示词工坊API
|
|||||||
app.include_router(book_import.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"
|
||||||
|
generated_assets_root_dir = Path(__file__).parent.parent / "storage"
|
||||||
|
generated_covers_dir = generated_assets_root_dir / "generated_covers"
|
||||||
|
generated_covers_dir.mkdir(parents=True, exist_ok=True)
|
||||||
if static_dir.exists():
|
if static_dir.exists():
|
||||||
app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets")
|
app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets")
|
||||||
|
app.mount("/generated-assets/covers", StaticFiles(directory=str(generated_covers_dir)), name="generated-covers")
|
||||||
|
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
async def serve_spa(full_path: str):
|
async def serve_spa(full_path: str):
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ class Project(Base):
|
|||||||
narrative_perspective = Column(String(50), comment="叙事视角:first_person/third_person/omniscient")
|
narrative_perspective = Column(String(50), comment="叙事视角:first_person/third_person/omniscient")
|
||||||
character_count = Column(Integer, default=5, comment="角色数量")
|
character_count = Column(Integer, default=5, comment="角色数量")
|
||||||
|
|
||||||
|
# 封面字段
|
||||||
|
cover_image_url = Column(String(1000), comment="封面图片访问地址")
|
||||||
|
cover_prompt = Column(Text, comment="最近一次生成封面使用的提示词")
|
||||||
|
cover_status = Column(String(20), default="none", nullable=False, comment="封面状态: none/generating/ready/failed")
|
||||||
|
cover_error = Column(Text, comment="最近一次封面生成失败原因")
|
||||||
|
cover_updated_at = Column(DateTime, comment="最近一次封面生成成功时间")
|
||||||
|
|
||||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||||
|
|
||||||
@@ -41,6 +48,10 @@ class Project(Base):
|
|||||||
"outline_mode IN ('one-to-one', 'one-to-many')",
|
"outline_mode IN ('one-to-one', 'one-to-many')",
|
||||||
name='check_outline_mode'
|
name='check_outline_mode'
|
||||||
),
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"cover_status IN ('none', 'generating', 'ready', 'failed')",
|
||||||
|
name='check_cover_status'
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""设置数据模型"""
|
"""设置数据模型"""
|
||||||
from sqlalchemy import Column, String, Text, Float, Integer, DateTime, Index
|
from sqlalchemy import Column, String, Text, Float, Integer, DateTime, Boolean, Index
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
import uuid
|
import uuid
|
||||||
@@ -18,6 +18,14 @@ class Settings(Base):
|
|||||||
temperature = Column(Float, default=0.7, comment="温度参数")
|
temperature = Column(Float, default=0.7, comment="温度参数")
|
||||||
max_tokens = Column(Integer, default=2000, comment="最大token数")
|
max_tokens = Column(Integer, default=2000, comment="最大token数")
|
||||||
system_prompt = Column(Text, comment="系统级别提示词,每次AI调用都会使用")
|
system_prompt = Column(Text, comment="系统级别提示词,每次AI调用都会使用")
|
||||||
|
|
||||||
|
# 封面图片生成配置
|
||||||
|
cover_api_provider = Column(String(50), comment="封面图片API提供商")
|
||||||
|
cover_api_key = Column(String(500), comment="封面图片API密钥")
|
||||||
|
cover_api_base_url = Column(String(500), comment="封面图片自定义API地址")
|
||||||
|
cover_image_model = Column(String(100), comment="封面图片模型名称")
|
||||||
|
cover_enabled = Column(Boolean, default=False, server_default="0", nullable=False, comment="是否启用封面图片生成")
|
||||||
|
|
||||||
preferences = Column(Text, comment="其他偏好设置(JSON)")
|
preferences = Column(Text, comment="其他偏好设置(JSON)")
|
||||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ class ProjectResponse(ProjectBase):
|
|||||||
chapter_count: Optional[int] = None
|
chapter_count: Optional[int] = None
|
||||||
narrative_perspective: Optional[str] = None
|
narrative_perspective: Optional[str] = None
|
||||||
character_count: Optional[int] = None
|
character_count: Optional[int] = None
|
||||||
|
cover_image_url: Optional[str] = None
|
||||||
|
cover_prompt: Optional[str] = None
|
||||||
|
cover_status: Optional[str] = None
|
||||||
|
cover_error: Optional[str] = None
|
||||||
|
cover_updated_at: Optional[datetime] = None
|
||||||
outline_mode: str # 显式声明以确保响应中包含
|
outline_mode: str # 显式声明以确保响应中包含
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ class SettingsBase(BaseModel):
|
|||||||
temperature: Optional[float] = Field(default=0.7, ge=0.0, le=2.0, description="温度参数")
|
temperature: Optional[float] = Field(default=0.7, ge=0.0, le=2.0, description="温度参数")
|
||||||
max_tokens: Optional[int] = Field(default=2000, ge=1, description="最大token数")
|
max_tokens: Optional[int] = Field(default=2000, ge=1, description="最大token数")
|
||||||
system_prompt: Optional[str] = Field(default=None, description="系统级别提示词,每次AI调用都会使用")
|
system_prompt: Optional[str] = Field(default=None, description="系统级别提示词,每次AI调用都会使用")
|
||||||
|
cover_api_provider: Optional[str] = Field(default=None, description="封面图片API提供商")
|
||||||
|
cover_api_key: Optional[str] = Field(default=None, description="封面图片API密钥")
|
||||||
|
cover_api_base_url: Optional[str] = Field(default=None, description="封面图片自定义API地址")
|
||||||
|
cover_image_model: Optional[str] = Field(default=None, description="封面图片模型名称")
|
||||||
|
cover_enabled: Optional[bool] = Field(default=False, description="是否启用封面图片生成")
|
||||||
preferences: Optional[str] = Field(default=None, description="其他偏好设置(JSON)")
|
preferences: Optional[str] = Field(default=None, description="其他偏好设置(JSON)")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""小说封面生成服务"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import PROJECT_ROOT
|
||||||
|
from app.logger import get_logger
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.settings import Settings
|
||||||
|
from app.services.cover_providers.base_cover_provider import BaseCoverProvider, CoverGenerationResult
|
||||||
|
from app.services.cover_providers.gemini_cover_provider import GeminiCoverProvider
|
||||||
|
from app.services.cover_providers.grok_cover_provider import GrokCoverProvider
|
||||||
|
from app.services.prompt_service import PromptService
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
COVER_WIDTH = 1024
|
||||||
|
COVER_HEIGHT = 1536
|
||||||
|
GENERATED_COVER_STORAGE_DIR = PROJECT_ROOT / "storage" / "generated_covers"
|
||||||
|
GENERATED_COVER_PUBLIC_PREFIX = "/generated-assets/covers"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CoverTestResult:
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
provider: Optional[str] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CoverGenerationService:
|
||||||
|
"""封面生成服务"""
|
||||||
|
|
||||||
|
async def generate_cover(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
project_id: str,
|
||||||
|
overwrite: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
project = await self._get_project(db=db, user_id=user_id, project_id=project_id)
|
||||||
|
settings = await self._get_settings(db=db, user_id=user_id)
|
||||||
|
self._validate_cover_settings(settings)
|
||||||
|
|
||||||
|
if project.cover_status == "generating":
|
||||||
|
raise HTTPException(status_code=409, detail="封面正在生成中,请勿重复提交")
|
||||||
|
if project.cover_status == "ready" and project.cover_image_url and not overwrite:
|
||||||
|
raise HTTPException(status_code=400, detail="当前项目已存在封面,如需覆盖请传入 overwrite=true")
|
||||||
|
|
||||||
|
prompt = await PromptService.build_novel_cover_prompt(
|
||||||
|
project,
|
||||||
|
user_id=user_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
project.cover_status = "generating"
|
||||||
|
project.cover_error = None
|
||||||
|
project.cover_prompt = prompt
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(project)
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = self._build_provider(settings)
|
||||||
|
result = await provider.generate_cover(
|
||||||
|
prompt=prompt,
|
||||||
|
model=settings.cover_image_model or "",
|
||||||
|
width=COVER_WIDTH,
|
||||||
|
height=COVER_HEIGHT,
|
||||||
|
)
|
||||||
|
image_url = self._save_cover_file(
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project.id,
|
||||||
|
content=result["content"],
|
||||||
|
file_extension=result["file_extension"],
|
||||||
|
)
|
||||||
|
|
||||||
|
project.cover_image_url = image_url
|
||||||
|
project.cover_status = "ready"
|
||||||
|
project.cover_error = None
|
||||||
|
project.cover_updated_at = datetime.utcnow()
|
||||||
|
project.cover_prompt = result.get("revised_prompt") or prompt
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(project)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project_id": project.id,
|
||||||
|
"cover_status": project.cover_status,
|
||||||
|
"cover_image_url": project.cover_image_url,
|
||||||
|
"cover_prompt": project.cover_prompt,
|
||||||
|
"provider": result["provider"],
|
||||||
|
"model": result["model"],
|
||||||
|
"message": "封面生成成功",
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("封面生成失败: project_id=%s error=%s", project.id, exc, exc_info=True)
|
||||||
|
project.cover_status = "failed"
|
||||||
|
project.cover_error = str(exc)
|
||||||
|
await db.commit()
|
||||||
|
raise HTTPException(status_code=500, detail=f"封面生成失败: {exc}") from exc
|
||||||
|
|
||||||
|
async def test_cover_settings(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
provider: str,
|
||||||
|
api_key: str,
|
||||||
|
api_base_url: Optional[str],
|
||||||
|
model: str,
|
||||||
|
) -> CoverTestResult:
|
||||||
|
if not provider or not api_key or not model:
|
||||||
|
raise HTTPException(status_code=400, detail="封面图片配置不完整,请填写 provider、api_key 和 model")
|
||||||
|
|
||||||
|
provider_instance = self._build_provider_from_values(
|
||||||
|
provider=provider,
|
||||||
|
api_key=api_key,
|
||||||
|
api_base_url=api_base_url,
|
||||||
|
)
|
||||||
|
test_prompt = (
|
||||||
|
"Create a clean fantasy novel cover illustration, vertical book cover, "
|
||||||
|
"standard 2:3 ratio, atmospheric lighting, no text, no watermark."
|
||||||
|
)
|
||||||
|
await provider_instance.generate_cover(
|
||||||
|
prompt=test_prompt,
|
||||||
|
model=model,
|
||||||
|
width=COVER_WIDTH,
|
||||||
|
height=COVER_HEIGHT,
|
||||||
|
)
|
||||||
|
return CoverTestResult(
|
||||||
|
success=True,
|
||||||
|
message="封面图片接口测试成功",
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_cover_download_path(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
project_id: str,
|
||||||
|
) -> tuple[Project, Path]:
|
||||||
|
project = await self._get_project(db=db, user_id=user_id, project_id=project_id)
|
||||||
|
if project.cover_status != "ready" or not project.cover_image_url:
|
||||||
|
raise HTTPException(status_code=404, detail="当前项目尚未生成可下载的封面")
|
||||||
|
|
||||||
|
absolute_path = self._resolve_cover_path(project.cover_image_url)
|
||||||
|
if not absolute_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="封面文件不存在,请重新生成")
|
||||||
|
return project, absolute_path
|
||||||
|
|
||||||
|
async def clear_cover_metadata(self, *, db: AsyncSession, project: Project) -> None:
|
||||||
|
project.cover_image_url = None
|
||||||
|
project.cover_prompt = None
|
||||||
|
project.cover_status = "none"
|
||||||
|
project.cover_error = None
|
||||||
|
project.cover_updated_at = None
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def _get_project(self, *, db: AsyncSession, user_id: str, project_id: str) -> 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_settings(self, *, db: AsyncSession, user_id: str) -> Settings:
|
||||||
|
result = await db.execute(select(Settings).where(Settings.user_id == user_id))
|
||||||
|
settings = result.scalar_one_or_none()
|
||||||
|
if not settings:
|
||||||
|
raise HTTPException(status_code=400, detail="请先在设置页完成封面图片配置")
|
||||||
|
return settings
|
||||||
|
|
||||||
|
def _validate_cover_settings(self, settings: Settings) -> None:
|
||||||
|
if not settings.cover_enabled:
|
||||||
|
raise HTTPException(status_code=400, detail="封面图片功能未启用,请先在设置页开启")
|
||||||
|
if not settings.cover_api_provider or not settings.cover_api_key or not settings.cover_image_model:
|
||||||
|
raise HTTPException(status_code=400, detail="封面图片配置不完整,请前往设置页补全")
|
||||||
|
|
||||||
|
def _build_provider(self, settings: Settings) -> BaseCoverProvider:
|
||||||
|
return self._build_provider_from_values(
|
||||||
|
provider=settings.cover_api_provider or "",
|
||||||
|
api_key=settings.cover_api_key or "",
|
||||||
|
api_base_url=settings.cover_api_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_provider_from_values(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
provider: str,
|
||||||
|
api_key: str,
|
||||||
|
api_base_url: Optional[str],
|
||||||
|
) -> BaseCoverProvider:
|
||||||
|
provider_value = (provider or "").lower().strip()
|
||||||
|
normalized_base_url = (api_base_url or "").rstrip("/")
|
||||||
|
if provider_value == "gemini":
|
||||||
|
return GeminiCoverProvider(api_key=api_key, base_url=normalized_base_url)
|
||||||
|
if provider_value == "grok":
|
||||||
|
return GrokCoverProvider(api_key=api_key, base_url=normalized_base_url)
|
||||||
|
if provider_value == "mumu":
|
||||||
|
if normalized_base_url.endswith("/v1beta"):
|
||||||
|
return GeminiCoverProvider(api_key=api_key, base_url=normalized_base_url)
|
||||||
|
return GrokCoverProvider(api_key=api_key, base_url=normalized_base_url or "https://api.mumuverse.space/v1")
|
||||||
|
raise HTTPException(status_code=400, detail="当前版本仅支持 Gemini、Grok 或 MuMuのAPI 作为封面图片 Provider")
|
||||||
|
|
||||||
|
def _save_cover_file(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
project_id: str,
|
||||||
|
content: bytes,
|
||||||
|
file_extension: str,
|
||||||
|
) -> str:
|
||||||
|
user_dir = GENERATED_COVER_STORAGE_DIR / user_id
|
||||||
|
user_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
|
||||||
|
safe_extension = (file_extension or "png").lstrip(".")
|
||||||
|
filename = f"{project_id}_{timestamp}.{safe_extension}"
|
||||||
|
file_path = user_dir / filename
|
||||||
|
file_path.write_bytes(content)
|
||||||
|
logger.info("封面文件已保存: project_id=%s path=%s", project_id, file_path)
|
||||||
|
return f"{GENERATED_COVER_PUBLIC_PREFIX}/{quote(user_id)}/{quote(filename)}"
|
||||||
|
|
||||||
|
def _resolve_cover_path(self, cover_image_url: Optional[str]) -> Path:
|
||||||
|
if not cover_image_url:
|
||||||
|
raise HTTPException(status_code=404, detail="当前项目尚未生成可下载的封面")
|
||||||
|
|
||||||
|
if cover_image_url.startswith(f"{GENERATED_COVER_PUBLIC_PREFIX}/"):
|
||||||
|
relative_path = cover_image_url.replace(f"{GENERATED_COVER_PUBLIC_PREFIX}/", "", 1)
|
||||||
|
return GENERATED_COVER_STORAGE_DIR / relative_path
|
||||||
|
|
||||||
|
if cover_image_url.startswith("/assets/generated_covers/"):
|
||||||
|
relative_path = cover_image_url.replace("/assets/generated_covers/", "", 1)
|
||||||
|
return GENERATED_COVER_STORAGE_DIR / relative_path
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="封面文件路径无效,请重新生成")
|
||||||
|
|
||||||
|
|
||||||
|
cover_generation_service = CoverGenerationService()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""封面图片 Provider 抽象基类"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional, TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class CoverGenerationResult(TypedDict):
|
||||||
|
"""封面生成结果"""
|
||||||
|
|
||||||
|
content: bytes
|
||||||
|
mime_type: str
|
||||||
|
file_extension: str
|
||||||
|
revised_prompt: Optional[str]
|
||||||
|
provider: str
|
||||||
|
model: str
|
||||||
|
|
||||||
|
|
||||||
|
class BaseCoverProvider(ABC):
|
||||||
|
"""封面图片 Provider 抽象基类"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def generate_cover(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
) -> CoverGenerationResult:
|
||||||
|
"""生成封面图片"""
|
||||||
|
raise NotImplementedError
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Gemini 封面图片 Provider"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.logger import get_logger
|
||||||
|
from app.services.cover_providers.base_cover_provider import BaseCoverProvider, CoverGenerationResult
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GeminiCoverProvider(BaseCoverProvider):
|
||||||
|
"""基于 Gemini API 的封面生成实现"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, base_url: str):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.base_url = (base_url or "https://generativelanguage.googleapis.com/v1beta").rstrip("/")
|
||||||
|
|
||||||
|
async def generate_cover(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
) -> CoverGenerationResult:
|
||||||
|
url = f"{self.base_url}/models/{model}:generateContent?key={self.api_key}"
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"contents": [{
|
||||||
|
"role": "user",
|
||||||
|
"parts": [{
|
||||||
|
"text": (
|
||||||
|
f"{prompt}\n\n"
|
||||||
|
f"Generate a final cover image at {width}x{height} pixels. "
|
||||||
|
"Return one final cover image."
|
||||||
|
)
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
"generationConfig": {
|
||||||
|
"temperature": 0.4,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
candidates = data.get("candidates") or []
|
||||||
|
if not candidates:
|
||||||
|
raise ValueError("Gemini 未返回候选结果")
|
||||||
|
|
||||||
|
parts = candidates[0].get("content", {}).get("parts", [])
|
||||||
|
for part in parts:
|
||||||
|
inline_data = part.get("inlineData")
|
||||||
|
if not inline_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
mime_type = inline_data.get("mimeType", "image/png")
|
||||||
|
image_data = inline_data.get("data")
|
||||||
|
if not image_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_extension = "png" if "png" in mime_type else "jpg"
|
||||||
|
return {
|
||||||
|
"content": base64.b64decode(image_data),
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"file_extension": file_extension,
|
||||||
|
"revised_prompt": None,
|
||||||
|
"provider": "gemini",
|
||||||
|
"model": model,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error("Gemini 返回内容中未找到 inlineData 图像数据")
|
||||||
|
raise ValueError("Gemini 未返回图片数据")
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""Grok 封面图片 Provider"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import struct
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.logger import get_logger
|
||||||
|
from app.services.cover_providers.base_cover_provider import BaseCoverProvider, CoverGenerationResult
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GrokCoverProvider(BaseCoverProvider):
|
||||||
|
"""基于 xAI Grok Images API 的封面生成实现"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, base_url: str):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.base_url = (base_url or "https://api.x.ai/v1").rstrip("/")
|
||||||
|
|
||||||
|
async def generate_cover(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
) -> CoverGenerationResult:
|
||||||
|
result = await self._request_cover(
|
||||||
|
prompt=prompt,
|
||||||
|
model=model,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
)
|
||||||
|
return self._to_public_result(result)
|
||||||
|
|
||||||
|
async def _request_cover(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
url = f"{self.base_url}/images/generations"
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": self._adapt_prompt(prompt=prompt, width=width, height=height),
|
||||||
|
"n": 1,
|
||||||
|
"response_format": "b64_json",
|
||||||
|
"aspect_ratio": self._get_aspect_ratio(width=width, height=height),
|
||||||
|
"resolution": self._get_resolution(width=width, height=height),
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Grok 封面生成请求开始: url=%s model=%s width=%s height=%s prompt_len=%s prompt_preview=%s",
|
||||||
|
url,
|
||||||
|
model,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
len(prompt or ""),
|
||||||
|
(prompt or "")[:300].replace("\n", " "),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||||
|
response = await client.post(url, headers=headers, json=payload)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Grok 封面生成响应: status=%s content_type=%s headers=%s body_preview=%s",
|
||||||
|
response.status_code,
|
||||||
|
response.headers.get("content-type"),
|
||||||
|
{
|
||||||
|
"x-request-id": response.headers.get("x-request-id"),
|
||||||
|
"cf-ray": response.headers.get("cf-ray"),
|
||||||
|
"openai-processing-ms": response.headers.get("openai-processing-ms"),
|
||||||
|
},
|
||||||
|
response.text[:1000],
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Grok 封面生成 HTTP 错误: status=%s response=%s",
|
||||||
|
exc.response.status_code if exc.response else None,
|
||||||
|
exc.response.text[:2000] if exc.response is not None else None,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.error("Grok 封面生成请求异常", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
images = data.get("data") or []
|
||||||
|
logger.debug(
|
||||||
|
"Grok 封面生成解析结果: has_data=%s image_count=%s keys=%s",
|
||||||
|
bool(data),
|
||||||
|
len(images),
|
||||||
|
list(data.keys()) if isinstance(data, dict) else type(data).__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
logger.error("Grok 未返回图片结果: data=%s", data)
|
||||||
|
raise ValueError("Grok 未返回图片结果")
|
||||||
|
|
||||||
|
image_item = images[0]
|
||||||
|
revised_prompt = image_item.get("revised_prompt")
|
||||||
|
logger.debug(
|
||||||
|
"Grok 首张图片结果: keys=%s has_b64=%s has_url=%s revised_prompt_preview=%s",
|
||||||
|
list(image_item.keys()),
|
||||||
|
bool(image_item.get("b64_json")),
|
||||||
|
bool(image_item.get("url")),
|
||||||
|
(revised_prompt or "")[:300],
|
||||||
|
)
|
||||||
|
|
||||||
|
b64_json = image_item.get("b64_json")
|
||||||
|
if b64_json:
|
||||||
|
decoded_content = self._decode_base64_image(b64_json)
|
||||||
|
image_width, image_height = self._detect_image_size(decoded_content)
|
||||||
|
logger.debug(
|
||||||
|
"Grok 返回 base64 图片: bytes=%s mime=image/jpeg size=%sx%s",
|
||||||
|
len(decoded_content),
|
||||||
|
image_width,
|
||||||
|
image_height,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"content": decoded_content,
|
||||||
|
"mime_type": "image/jpeg",
|
||||||
|
"file_extension": "jpg",
|
||||||
|
"revised_prompt": revised_prompt,
|
||||||
|
"provider": "grok",
|
||||||
|
"model": model,
|
||||||
|
"image_width": image_width,
|
||||||
|
"image_height": image_height,
|
||||||
|
}
|
||||||
|
|
||||||
|
image_url = image_item.get("url")
|
||||||
|
if image_url:
|
||||||
|
logger.debug("Grok 返回图片 URL,开始下载: %s", image_url)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||||
|
image_response = await client.get(image_url)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Grok 图片下载响应: status=%s content_type=%s content_length=%s",
|
||||||
|
image_response.status_code,
|
||||||
|
image_response.headers.get("content-type"),
|
||||||
|
image_response.headers.get("content-length"),
|
||||||
|
)
|
||||||
|
image_response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Grok 图片下载 HTTP 错误: status=%s response=%s",
|
||||||
|
exc.response.status_code if exc.response else None,
|
||||||
|
exc.response.text[:2000] if exc.response is not None else None,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.error("Grok 图片下载异常", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
content_type = image_response.headers.get("content-type", "image/jpeg")
|
||||||
|
file_extension = self._guess_extension(content_type=content_type, image_url=image_url)
|
||||||
|
image_width, image_height = self._detect_image_size(image_response.content)
|
||||||
|
logger.debug(
|
||||||
|
"Grok 图片下载完成: bytes=%s extension=%s size=%sx%s",
|
||||||
|
len(image_response.content),
|
||||||
|
file_extension,
|
||||||
|
image_width,
|
||||||
|
image_height,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"content": image_response.content,
|
||||||
|
"mime_type": content_type,
|
||||||
|
"file_extension": file_extension,
|
||||||
|
"revised_prompt": revised_prompt,
|
||||||
|
"provider": "grok",
|
||||||
|
"model": model,
|
||||||
|
"image_width": image_width,
|
||||||
|
"image_height": image_height,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error("Grok 返回内容中既没有 b64_json,也没有 url: %s", data)
|
||||||
|
raise ValueError("Grok 未返回可用的图片数据")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_public_result(result: dict[str, Any]) -> CoverGenerationResult:
|
||||||
|
return {
|
||||||
|
"content": result["content"],
|
||||||
|
"mime_type": result["mime_type"],
|
||||||
|
"file_extension": result["file_extension"],
|
||||||
|
"revised_prompt": result.get("revised_prompt"),
|
||||||
|
"provider": result["provider"],
|
||||||
|
"model": result["model"],
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _detect_image_size(content: bytes) -> tuple[int, int]:
|
||||||
|
if len(content) >= 24 and content[:8] == b"\x89PNG\r\n\x1a\n":
|
||||||
|
width, height = struct.unpack(">II", content[16:24])
|
||||||
|
return int(width), int(height)
|
||||||
|
|
||||||
|
if len(content) >= 2 and content[:2] == b"\xff\xd8":
|
||||||
|
index = 2
|
||||||
|
content_length = len(content)
|
||||||
|
while index < content_length - 1:
|
||||||
|
if content[index] != 0xFF:
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
marker = content[index + 1]
|
||||||
|
index += 2
|
||||||
|
if marker in (0xD8, 0xD9):
|
||||||
|
continue
|
||||||
|
if index + 2 > content_length:
|
||||||
|
break
|
||||||
|
segment_length = struct.unpack(">H", content[index:index + 2])[0]
|
||||||
|
if segment_length < 2 or index + segment_length > content_length:
|
||||||
|
break
|
||||||
|
if marker in {
|
||||||
|
0xC0, 0xC1, 0xC2, 0xC3,
|
||||||
|
0xC5, 0xC6, 0xC7,
|
||||||
|
0xC9, 0xCA, 0xCB,
|
||||||
|
0xCD, 0xCE, 0xCF,
|
||||||
|
}:
|
||||||
|
if index + 7 <= content_length:
|
||||||
|
height, width = struct.unpack(">HH", content[index + 3:index + 7])
|
||||||
|
return int(width), int(height)
|
||||||
|
break
|
||||||
|
index += segment_length
|
||||||
|
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decode_base64_image(value: str) -> bytes:
|
||||||
|
if value.startswith("data:") and "," in value:
|
||||||
|
value = value.split(",", 1)[1]
|
||||||
|
return base64.b64decode(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _adapt_prompt(*, prompt: str, width: int, height: int) -> str:
|
||||||
|
cleaned_prompt = " ".join((prompt or "").split())
|
||||||
|
return (
|
||||||
|
f"{cleaned_prompt} "
|
||||||
|
f"Use a {width}x{height} vertical composition."
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_aspect_ratio(*, width: int, height: int) -> str:
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
return "2:3"
|
||||||
|
if width * 3 == height * 2:
|
||||||
|
return "2:3"
|
||||||
|
return f"{width}:{height}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_resolution(*, width: int, height: int) -> str:
|
||||||
|
longest_edge = max(width, height)
|
||||||
|
if longest_edge >= 1536:
|
||||||
|
return "2k"
|
||||||
|
return "1k"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _guess_extension(*, content_type: str, image_url: str) -> str:
|
||||||
|
lowered_content_type = (content_type or "").lower()
|
||||||
|
lowered_url = (image_url or "").lower()
|
||||||
|
if "png" in lowered_content_type or lowered_url.endswith(".png"):
|
||||||
|
return "png"
|
||||||
|
if "webp" in lowered_content_type or lowered_url.endswith(".webp"):
|
||||||
|
return "webp"
|
||||||
|
return "jpg"
|
||||||
@@ -25,6 +25,53 @@ class WritingStyleManager:
|
|||||||
class PromptService:
|
class PromptService:
|
||||||
"""提示词模板管理"""
|
"""提示词模板管理"""
|
||||||
|
|
||||||
|
NOVEL_COVER_PROMPT_TEMPLATE = """创作一幅高质量小说封面插图,适用于竖版书籍封面。
|
||||||
|
|
||||||
|
小说标题是:“{title}”。
|
||||||
|
类型为 {genre}。核心主题是 {theme}。故事摘要如下:{description}
|
||||||
|
|
||||||
|
画面应具有电影感、精致、富有氛围和情感表现力,并具备清晰的视觉焦点和强烈的象征性意象。请优先展现符合小说类型的视觉叙事和情绪,而不是死板地描绘具体场景。
|
||||||
|
|
||||||
|
这必须看起来像一幅专业的网络小说或实体出版物风格的封面。
|
||||||
|
|
||||||
|
硬性要求:
|
||||||
|
- 必须在画面醒目位置包含小说标题文字:“{title}”,文字排版需极具艺术感,并与小说的 {genre} 类型风格完美融合。
|
||||||
|
- 适用于标准小说封面的竖版构图(2:3 比例)。
|
||||||
|
- 画面中只能出现标题文字,绝不能出现作者名字、副标题或其他无关的随机字母。
|
||||||
|
- 无标志 (Logo)。
|
||||||
|
- 无水印。
|
||||||
|
- 无边框。
|
||||||
|
- 无 UI 元素。
|
||||||
|
- 无样机展示效果 (Mockup)。
|
||||||
|
|
||||||
|
最终图像必须是一张完整、专业的书籍封面艺术作品,背景插画与标题排版需相得益彰。"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def build_novel_cover_prompt(
|
||||||
|
cls,
|
||||||
|
project: Any,
|
||||||
|
user_id: str = None,
|
||||||
|
db = None,
|
||||||
|
) -> str:
|
||||||
|
"""基于项目基础信息构建小说封面提示词,支持用户自定义模板"""
|
||||||
|
title = (getattr(project, "title", "") or "未命名小说").strip()
|
||||||
|
genre = (getattr(project, "genre", "") or "未指定类型").strip()
|
||||||
|
theme = (getattr(project, "theme", "") or "未指定主题").strip()
|
||||||
|
description = (getattr(project, "description", "") or "无额外简介").strip()
|
||||||
|
|
||||||
|
compact_description = description[:300]
|
||||||
|
template = await cls.get_template_with_fallback(
|
||||||
|
"NOVEL_COVER_PROMPT_TEMPLATE",
|
||||||
|
user_id=user_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
return template.format(
|
||||||
|
title=title,
|
||||||
|
genre=genre,
|
||||||
|
theme=theme,
|
||||||
|
description=compact_description,
|
||||||
|
)
|
||||||
|
|
||||||
# ========== V2版本提示词模板(RTCO框架)==========
|
# ========== V2版本提示词模板(RTCO框架)==========
|
||||||
|
|
||||||
# 世界构建提示词 V2(RTCO框架)
|
# 世界构建提示词 V2(RTCO框架)
|
||||||
@@ -2813,6 +2860,12 @@ class PromptService:
|
|||||||
|
|
||||||
# 定义所有模板及其元信息
|
# 定义所有模板及其元信息
|
||||||
template_definitions = {
|
template_definitions = {
|
||||||
|
"NOVEL_COVER_PROMPT_TEMPLATE": {
|
||||||
|
"name": "小说封面生成",
|
||||||
|
"category": "封面生成",
|
||||||
|
"description": "根据项目基础信息生成小说封面绘制提示词,适用于竖版书籍封面",
|
||||||
|
"parameters": ["title", "genre", "theme", "description"]
|
||||||
|
},
|
||||||
"WORLD_BUILDING": {
|
"WORLD_BUILDING": {
|
||||||
"name": "世界构建",
|
"name": "世界构建",
|
||||||
"category": "世界构建",
|
"category": "世界构建",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const promptTemplateBaseShadow = `
|
|||||||
export const bookshelfCardStyles = {
|
export const bookshelfCardStyles = {
|
||||||
container: {
|
container: {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(270px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||||
gap: '20px 18px',
|
gap: '20px 18px',
|
||||||
padding: '8px 0 16px',
|
padding: '8px 0 16px',
|
||||||
alignItems: 'stretch',
|
alignItems: 'stretch',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Card, Button, Spin, Space, Tag, Typography, Alert, Tooltip, theme } from 'antd';
|
import { Card, Button, Spin, Space, Tag, Typography, Alert, theme } from 'antd';
|
||||||
import { BookOutlined, RocketOutlined, BulbOutlined, UploadOutlined, DownloadOutlined, LoadingOutlined, CalendarOutlined, DeleteOutlined, CheckCircleOutlined, EditOutlined, PauseCircleOutlined } from '@ant-design/icons';
|
import { BookOutlined, RocketOutlined, BulbOutlined, UploadOutlined, DownloadOutlined, LoadingOutlined, CalendarOutlined, DeleteOutlined, CheckCircleOutlined, EditOutlined, PauseCircleOutlined, PictureOutlined, SwapOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import { useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { Project } from '../types';
|
import type { Project } from '../types';
|
||||||
import { bookshelfCardStyles, bookshelfCardHoverHandlers } from '../components/CardStyles';
|
import { bookshelfCardStyles, bookshelfCardHoverHandlers } from '../components/CardStyles';
|
||||||
@@ -21,6 +22,8 @@ interface BookshelfPageProps {
|
|||||||
onOpenInspiration: () => void;
|
onOpenInspiration: () => void;
|
||||||
onEnterProject: (project: Project) => void;
|
onEnterProject: (project: Project) => void;
|
||||||
onDeleteProject: (projectId: string) => void;
|
onDeleteProject: (projectId: string) => void;
|
||||||
|
onGenerateCover: (project: Project, overwrite?: boolean) => void | Promise<void>;
|
||||||
|
onDownloadCover: (project: Project) => void;
|
||||||
formatWordCount: (count: number) => string;
|
formatWordCount: (count: number) => string;
|
||||||
getProgress: (current: number, target: number) => number;
|
getProgress: (current: number, target: number) => number;
|
||||||
getProgressColor: (progress: number) => string;
|
getProgressColor: (progress: number) => string;
|
||||||
@@ -43,6 +46,8 @@ export default function BookshelfPage({
|
|||||||
onOpenInspiration,
|
onOpenInspiration,
|
||||||
onEnterProject,
|
onEnterProject,
|
||||||
onDeleteProject,
|
onDeleteProject,
|
||||||
|
onGenerateCover,
|
||||||
|
onDownloadCover,
|
||||||
formatWordCount,
|
formatWordCount,
|
||||||
getProgress,
|
getProgress,
|
||||||
getProgressColor,
|
getProgressColor,
|
||||||
@@ -53,6 +58,8 @@ export default function BookshelfPage({
|
|||||||
const { resolvedMode } = useThemeMode();
|
const { resolvedMode } = useThemeMode();
|
||||||
const isDark = resolvedMode === 'dark';
|
const isDark = resolvedMode === 'dark';
|
||||||
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
|
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
|
||||||
|
const [flippedProjectIds, setFlippedProjectIds] = useState<Record<string, boolean>>({});
|
||||||
|
const [coverGeneratingIds, setCoverGeneratingIds] = useState<Record<string, boolean>>({});
|
||||||
const mobileBookHeight = 460;
|
const mobileBookHeight = 460;
|
||||||
const desktopBookHeight = 430;
|
const desktopBookHeight = 430;
|
||||||
const mobileSpineWidth = 32;
|
const mobileSpineWidth = 32;
|
||||||
@@ -105,6 +112,26 @@ export default function BookshelfPage({
|
|||||||
return <EditOutlined style={commonStyle} />;
|
return <EditOutlined style={commonStyle} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleProjectFace = (projectId: string) => {
|
||||||
|
setFlippedProjectIds((prev) => ({ ...prev, [projectId]: !prev[projectId] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateCoverClick = async (event: React.MouseEvent<HTMLElement>, project: Project, overwrite: boolean = true) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (coverGeneratingIds[project.id]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCoverGeneratingIds((prev) => ({ ...prev, [project.id]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onGenerateCover(project, overwrite);
|
||||||
|
} finally {
|
||||||
|
setCoverGeneratingIds((prev) => ({ ...prev, [project.id]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Card
|
<Card
|
||||||
@@ -333,6 +360,12 @@ export default function BookshelfPage({
|
|||||||
|
|
||||||
const ribbonStatusIcon = getRibbonStatusIcon(displayStatus, isWizardIncomplete, isCompleted);
|
const ribbonStatusIcon = getRibbonStatusIcon(displayStatus, isWizardIncomplete, isCompleted);
|
||||||
|
|
||||||
|
const isFlipped = !!flippedProjectIds[project.id];
|
||||||
|
const coverActionLoading = !!coverGeneratingIds[project.id];
|
||||||
|
const coverReady = project.cover_status === 'ready' && !!project.cover_image_url;
|
||||||
|
const coverGenerating = project.cover_status === 'generating' || coverActionLoading;
|
||||||
|
const coverFailed = project.cover_status === 'failed' && !coverActionLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={project.id} style={{ position: 'relative', width: '100%', minWidth: 0, minHeight: isMobile ? mobileBookHeight : desktopBookHeight }}>
|
<div key={project.id} style={{ position: 'relative', width: '100%', minWidth: 0, minHeight: isMobile ? mobileBookHeight : desktopBookHeight }}>
|
||||||
<Card
|
<Card
|
||||||
@@ -340,7 +373,7 @@ export default function BookshelfPage({
|
|||||||
style={{ ...bookshelfCardStyles.projectCard, minHeight: isMobile ? mobileBookHeight : desktopBookHeight }}
|
style={{ ...bookshelfCardStyles.projectCard, minHeight: isMobile ? mobileBookHeight : desktopBookHeight }}
|
||||||
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
|
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
|
||||||
{...bookshelfCardHoverHandlers}
|
{...bookshelfCardHoverHandlers}
|
||||||
onClick={() => onEnterProject(project)}
|
onClick={() => !isFlipped && onEnterProject(project)}
|
||||||
data-card-style="bookshelf-book"
|
data-card-style="bookshelf-book"
|
||||||
data-book-kind="project"
|
data-book-kind="project"
|
||||||
>
|
>
|
||||||
@@ -384,200 +417,267 @@ export default function BookshelfPage({
|
|||||||
minHeight: isMobile ? mobileBookHeight : desktopBookHeight,
|
minHeight: isMobile ? mobileBookHeight : desktopBookHeight,
|
||||||
padding: isMobile ? '18px 16px 14px 38px' : '26px 24px 18px 42px',
|
padding: isMobile ? '18px 16px 14px 38px' : '26px 24px 18px 42px',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ marginBottom: isMobile ? 10 : 12, paddingRight: isMobile ? 18 : 30 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 8 }}>
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: isMobile ? 8 : 10,
|
|
||||||
minHeight: isMobile ? 50 : 58,
|
|
||||||
}}>
|
|
||||||
<BookOutlined style={{
|
|
||||||
fontSize: isMobile ? 14 : 16,
|
|
||||||
color: alphaColor(token.colorText, 0.4),
|
|
||||||
marginTop: 2,
|
|
||||||
flexShrink: 0,
|
|
||||||
}} />
|
|
||||||
<Tooltip title={project.title}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: isMobile ? 18 : 22,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: token.colorText,
|
|
||||||
lineHeight: 1.3,
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
overflow: 'hidden',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
fontFamily: 'Georgia, "Times New Roman", "Noto Serif SC", serif',
|
|
||||||
}}>
|
|
||||||
{project.title}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 6,
|
|
||||||
minHeight: isMobile ? 20 : 22,
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
}}>
|
|
||||||
{tags.length > 0 ? tags.slice(0, 3).map((tag: string, idx: number) => (
|
|
||||||
<Tag key={idx} style={{
|
|
||||||
margin: 0,
|
|
||||||
padding: isMobile ? '0 7px' : '0 8px',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: `1px solid ${alphaColor(token.colorSuccess, 0.18)}`,
|
|
||||||
background: alphaColor(token.colorSuccess, 0.08),
|
|
||||||
color: token.colorSuccess,
|
|
||||||
fontSize: isMobile ? 10 : 11,
|
|
||||||
lineHeight: isMobile ? '18px' : '20px',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
{tag}
|
|
||||||
</Tag>
|
|
||||||
)) : (
|
|
||||||
<Tag style={{
|
|
||||||
margin: 0,
|
|
||||||
padding: isMobile ? '0 7px' : '0 8px',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: `1px solid ${alphaColor(token.colorSuccess, 0.18)}`,
|
|
||||||
background: alphaColor(token.colorSuccess, 0.08),
|
|
||||||
color: token.colorSuccess,
|
|
||||||
fontSize: isMobile ? 10 : 11,
|
|
||||||
lineHeight: isMobile ? '18px' : '20px',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
未分类
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Paragraph
|
|
||||||
ellipsis={{ rows: isMobile ? 3 : 3 }}
|
|
||||||
style={{
|
|
||||||
fontSize: isMobile ? 12 : 13,
|
|
||||||
color: token.colorTextSecondary,
|
|
||||||
marginBottom: isMobile ? 12 : 16,
|
|
||||||
lineHeight: 1.7,
|
|
||||||
flexGrow: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{project.description || '暂无描述...'}
|
|
||||||
</Paragraph>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: isMobile ? 14 : 18 }}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: isMobile ? 11 : 12,
|
|
||||||
}}>
|
|
||||||
<span style={{ color: token.colorTextTertiary }}>完成进度</span>
|
|
||||||
<span style={{ color: progressColor, fontWeight: 700 }}>{progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
height: 6,
|
|
||||||
width: '100%',
|
|
||||||
borderRadius: 999,
|
|
||||||
overflow: 'hidden',
|
|
||||||
background: alphaColor(token.colorText, 0.06),
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: `${progress}%`,
|
|
||||||
height: '100%',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: progressColor,
|
|
||||||
transition: 'width 0.3s ease',
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
marginBottom: isMobile ? 12 : 16,
|
|
||||||
padding: isMobile ? '12px 10px' : '14px 12px',
|
|
||||||
background: `linear-gradient(180deg, ${alphaColor(token.colorBgContainer, 0.94)} 0%, ${alphaColor(token.colorFillSecondary, 0.78)} 100%)`,
|
|
||||||
borderRadius: 10,
|
|
||||||
border: `1px solid ${alphaColor(token.colorText, 0.06)}`,
|
|
||||||
boxShadow: `inset 0 1px 2px ${alphaColor(token.colorText, 0.08)}`,
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'stretch', textAlign: 'center' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: isMobile ? 22 : 26,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: token.colorText,
|
|
||||||
lineHeight: 1.1,
|
|
||||||
fontFamily: 'Georgia, "Times New Roman", serif',
|
|
||||||
}}>
|
|
||||||
{formatWordCount(project.current_words || 0)}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: isMobile ? 10 : 11,
|
|
||||||
color: token.colorTextTertiary,
|
|
||||||
marginTop: 4,
|
|
||||||
}}>
|
|
||||||
已写字数
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
width: 1,
|
|
||||||
margin: '0 12px',
|
|
||||||
background: alphaColor(token.colorText, 0.1),
|
|
||||||
}} />
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: isMobile ? 22 : 26,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: progress >= 100 ? token.colorSuccess : progressColor,
|
|
||||||
lineHeight: 1.1,
|
|
||||||
fontFamily: 'Georgia, "Times New Roman", serif',
|
|
||||||
}}>
|
|
||||||
{formatWordCount(project.target_words || 0)}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: isMobile ? 10 : 11,
|
|
||||||
color: token.colorTextTertiary,
|
|
||||||
marginTop: 4,
|
|
||||||
}}>
|
|
||||||
目标字数
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingTop: isMobile ? 10 : 12,
|
|
||||||
borderTop: `1px solid ${alphaColor(token.colorText, 0.06)}`,
|
|
||||||
color: token.colorTextTertiary,
|
|
||||||
marginTop: 'auto',
|
|
||||||
}}>
|
|
||||||
<Space size={4} style={{ fontSize: isMobile ? 11 : 12, color: token.colorTextTertiary }}>
|
|
||||||
<CalendarOutlined style={{ fontSize: isMobile ? 10 : 12 }} />
|
|
||||||
{formatDate(project.updated_at)}
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
danger
|
icon={isFlipped ? <BookOutlined /> : <SwapOutlined />}
|
||||||
icon={<DeleteOutlined style={{ fontSize: isMobile ? 12 : 14 }} />}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDeleteProject(project.id);
|
toggleProjectFace(project.id);
|
||||||
}}
|
}}
|
||||||
style={{
|
>
|
||||||
padding: isMobile ? '2px 4px' : '4px 8px',
|
{isFlipped ? '返回书本' : '查看封面'}
|
||||||
borderRadius: 8,
|
</Button>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{isFlipped ? (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, justifyContent: 'center' }}>
|
||||||
|
{coverReady ? (
|
||||||
|
<>
|
||||||
|
<div style={{ flex: 1, minHeight: 240, borderRadius: 12, overflow: 'hidden', background: alphaColor(token.colorText, 0.06), border: `1px solid ${alphaColor(token.colorText, 0.08)}` }}>
|
||||||
|
<img src={project.cover_image_url} alt={`${project.title} cover`} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
|
</div>
|
||||||
|
<Space wrap>
|
||||||
|
<Button icon={<DownloadOutlined />} onClick={(e) => { e.stopPropagation(); onDownloadCover(project); }} disabled={coverActionLoading}>下载封面</Button>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={coverActionLoading}
|
||||||
|
onClick={(e) => void handleGenerateCoverClick(e, project, true)}
|
||||||
|
>
|
||||||
|
{coverActionLoading ? '重新生成中...' : '重新生成'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
) : coverGenerating ? (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
||||||
|
<LoadingOutlined spin style={{ fontSize: 28, color: token.colorPrimary }} />
|
||||||
|
<div style={{ color: token.colorTextSecondary }}>封面生成中,请稍候...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, textAlign: 'center' }}>
|
||||||
|
<PictureOutlined style={{ fontSize: 36, color: token.colorTextTertiary }} />
|
||||||
|
{coverFailed ? (
|
||||||
|
<>
|
||||||
|
<div style={{ color: token.colorError }}>封面生成失败</div>
|
||||||
|
<div style={{ color: token.colorTextSecondary, fontSize: 12 }}>{project.cover_error || '请稍后重试'}</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={coverActionLoading}
|
||||||
|
onClick={(e) => void handleGenerateCoverClick(e, project, true)}
|
||||||
|
>
|
||||||
|
{coverActionLoading ? '重新生成中...' : '重新生成'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PictureOutlined />}
|
||||||
|
loading={coverActionLoading}
|
||||||
|
onClick={(e) => void handleGenerateCoverClick(e, project, true)}
|
||||||
|
>
|
||||||
|
{coverActionLoading ? '生成中...' : '生成封面'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: isMobile ? 10 : 12, paddingRight: isMobile ? 18 : 30 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: isMobile ? 8 : 10,
|
||||||
|
minHeight: isMobile ? 50 : 58,
|
||||||
|
}}>
|
||||||
|
<BookOutlined style={{
|
||||||
|
fontSize: isMobile ? 14 : 16,
|
||||||
|
color: alphaColor(token.colorText, 0.4),
|
||||||
|
marginTop: 2,
|
||||||
|
flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
fontSize: isMobile ? 18 : 22,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: token.colorText,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
fontFamily: 'Georgia, "Times New Roman", "Noto Serif SC", serif',
|
||||||
|
}}>
|
||||||
|
{project.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 6,
|
||||||
|
minHeight: isMobile ? 20 : 22,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}>
|
||||||
|
{tags.length > 0 ? tags.slice(0, 3).map((tag: string, idx: number) => (
|
||||||
|
<Tag key={idx} style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: isMobile ? '0 7px' : '0 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid ${alphaColor(token.colorSuccess, 0.18)}`,
|
||||||
|
background: alphaColor(token.colorSuccess, 0.08),
|
||||||
|
color: token.colorSuccess,
|
||||||
|
fontSize: isMobile ? 10 : 11,
|
||||||
|
lineHeight: isMobile ? '18px' : '20px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
)) : (
|
||||||
|
<Tag style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: isMobile ? '0 7px' : '0 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid ${alphaColor(token.colorSuccess, 0.18)}`,
|
||||||
|
background: alphaColor(token.colorSuccess, 0.08),
|
||||||
|
color: token.colorSuccess,
|
||||||
|
fontSize: isMobile ? 10 : 11,
|
||||||
|
lineHeight: isMobile ? '18px' : '20px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
未分类
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Paragraph
|
||||||
|
ellipsis={{ rows: isMobile ? 3 : 3 }}
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? 12 : 13,
|
||||||
|
color: token.colorTextSecondary,
|
||||||
|
marginBottom: isMobile ? 12 : 16,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
flexGrow: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.description || '暂无描述...'}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: isMobile ? 14 : 18 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: isMobile ? 11 : 12,
|
||||||
|
}}>
|
||||||
|
<span style={{ color: token.colorTextTertiary }}>完成进度</span>
|
||||||
|
<span style={{ color: progressColor, fontWeight: 700 }}>{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
height: 6,
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: alphaColor(token.colorText, 0.06),
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: `${progress}%`,
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: progressColor,
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginBottom: isMobile ? 12 : 16,
|
||||||
|
padding: isMobile ? '12px 10px' : '14px 12px',
|
||||||
|
background: `linear-gradient(180deg, ${alphaColor(token.colorBgContainer, 0.94)} 0%, ${alphaColor(token.colorFillSecondary, 0.78)} 100%)`,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${alphaColor(token.colorText, 0.06)}`,
|
||||||
|
boxShadow: `inset 0 1px 2px ${alphaColor(token.colorText, 0.08)}`,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'stretch', textAlign: 'center' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: isMobile ? 22 : 26,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: token.colorText,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
fontFamily: 'Georgia, "Times New Roman", serif',
|
||||||
|
}}>
|
||||||
|
{formatWordCount(project.current_words || 0)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: isMobile ? 10 : 11,
|
||||||
|
color: token.colorTextTertiary,
|
||||||
|
marginTop: 4,
|
||||||
|
}}>
|
||||||
|
已写字数
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
width: 1,
|
||||||
|
margin: '0 12px',
|
||||||
|
background: alphaColor(token.colorText, 0.1),
|
||||||
|
}} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: isMobile ? 22 : 26,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: progress >= 100 ? token.colorSuccess : progressColor,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
fontFamily: 'Georgia, "Times New Roman", serif',
|
||||||
|
}}>
|
||||||
|
{formatWordCount(project.target_words || 0)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: isMobile ? 10 : 11,
|
||||||
|
color: token.colorTextTertiary,
|
||||||
|
marginTop: 4,
|
||||||
|
}}>
|
||||||
|
目标字数
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: isMobile ? 10 : 12,
|
||||||
|
borderTop: `1px solid ${alphaColor(token.colorText, 0.06)}`,
|
||||||
|
color: token.colorTextTertiary,
|
||||||
|
marginTop: 'auto',
|
||||||
|
}}>
|
||||||
|
<Space size={4} style={{ fontSize: isMobile ? 11 : 12, color: token.colorTextTertiary }}>
|
||||||
|
<CalendarOutlined style={{ fontSize: isMobile ? 10 : 12 }} />
|
||||||
|
{formatDate(project.updated_at)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined style={{ fontSize: isMobile ? 12 : 14 }} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteProject(project.id);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: isMobile ? '2px 4px' : '4px 8px',
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -172,6 +172,28 @@ export default function ProjectList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateCover = async (project: Project, overwrite: boolean = true) => {
|
||||||
|
try {
|
||||||
|
message.loading({ content: `正在为《${project.title}》生成封面...`, key: `cover-${project.id}` });
|
||||||
|
await projectApi.generateCover(project.id, overwrite);
|
||||||
|
message.success({ content: `《${project.title}》封面生成成功`, key: `cover-${project.id}` });
|
||||||
|
await refreshProjects();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('生成封面失败:', error);
|
||||||
|
message.error({ content: `《${project.title}》封面生成失败`, key: `cover-${project.id}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadCover = async (project: Project) => {
|
||||||
|
try {
|
||||||
|
await projectApi.downloadCover(project.id, `${project.title}-cover.png`);
|
||||||
|
message.success(`《${project.title}》封面已开始下载`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下载封面失败:', error);
|
||||||
|
message.error('下载封面失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusTag = (status: string) => {
|
const getStatusTag = (status: string) => {
|
||||||
const statusConfig: Record<string, { color: string; text: string; icon: ReactNode }> = {
|
const statusConfig: Record<string, { color: string; text: string; icon: ReactNode }> = {
|
||||||
planning: { color: 'blue', text: '规划', icon: <CalendarOutlined /> },
|
planning: { color: 'blue', text: '规划', icon: <CalendarOutlined /> },
|
||||||
@@ -837,6 +859,8 @@ export default function ProjectList() {
|
|||||||
onOpenInspiration={() => navigate('/inspiration')}
|
onOpenInspiration={() => navigate('/inspiration')}
|
||||||
onEnterProject={handleEnterProject}
|
onEnterProject={handleEnterProject}
|
||||||
onDeleteProject={handleDelete}
|
onDeleteProject={handleDelete}
|
||||||
|
onGenerateCover={handleGenerateCover}
|
||||||
|
onDownloadCover={handleDownloadCover}
|
||||||
formatWordCount={formatWordCount}
|
formatWordCount={formatWordCount}
|
||||||
getProgress={getProgress}
|
getProgress={getProgress}
|
||||||
getProgressColor={getProgressColor}
|
getProgressColor={getProgressColor}
|
||||||
|
|||||||
+213
-10
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Card, Form, Input, Button, Select, Slider, InputNumber, message, Space, Typography, Spin, Modal, Alert, Grid, Tabs, List, Tag, Popconfirm, Empty, Row, Col, theme } from 'antd';
|
import { Card, Form, Input, Button, Select, Slider, InputNumber, message, Space, Typography, Spin, Modal, Alert, Grid, Tabs, List, Tag, Popconfirm, Empty, Row, Col, theme } from 'antd';
|
||||||
import { SaveOutlined, DeleteOutlined, ReloadOutlined, InfoCircleOutlined, CheckCircleOutlined, CloseCircleOutlined, ThunderboltOutlined, PlusOutlined, EditOutlined, CopyOutlined, WarningOutlined } from '@ant-design/icons';
|
import { SaveOutlined, DeleteOutlined, ReloadOutlined, InfoCircleOutlined, CheckCircleOutlined, CloseCircleOutlined, ThunderboltOutlined, PlusOutlined, EditOutlined, CopyOutlined, WarningOutlined, PictureOutlined } from '@ant-design/icons';
|
||||||
import { settingsApi, mcpPluginApi } from '../services/api';
|
import { settingsApi, mcpPluginApi } from '../services/api';
|
||||||
import type { SettingsUpdate, APIKeyPreset, PresetCreateRequest, APIKeyPresetConfig } from '../types';
|
import type { SettingsUpdate, APIKeyPreset, PresetCreateRequest, APIKeyPresetConfig } from '../types';
|
||||||
import { eventBus, EventNames } from '../store/eventBus';
|
import { eventBus, EventNames } from '../store/eventBus';
|
||||||
@@ -35,6 +35,13 @@ export default function SettingsPage() {
|
|||||||
suggestions?: string[];
|
suggestions?: string[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [showTestResult, setShowTestResult] = useState(false);
|
const [showTestResult, setShowTestResult] = useState(false);
|
||||||
|
const [testingCoverApi, setTestingCoverApi] = useState(false);
|
||||||
|
const [coverTestResult, setCoverTestResult] = useState<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// 预设相关状态
|
// 预设相关状态
|
||||||
const [activeTab, setActiveTab] = useState('current');
|
const [activeTab, setActiveTab] = useState('current');
|
||||||
@@ -265,19 +272,27 @@ export default function SettingsPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mumuTextDefaultUrl = 'https://api.mumuverse.space/v1';
|
||||||
|
const mumuRegisterUrl = 'https://api.mumuverse.space/register?aff=4NN8';
|
||||||
|
const mumuCoverBaseUrlOptions = [
|
||||||
|
{ value: 'https://api.mumuverse.space/v1beta', label: 'https://api.mumuverse.space/v1beta', defaultModel: 'gemini-3.1-flash-image-preview' },
|
||||||
|
{ value: 'https://api.mumuverse.space/v1', label: 'https://api.mumuverse.space/v1', defaultModel: 'gpt-image-1.5' },
|
||||||
|
];
|
||||||
|
|
||||||
const apiProviders = [
|
const apiProviders = [
|
||||||
{ value: 'openai', label: 'OpenAI Compatible', defaultUrl: 'https://api.openai.com/v1' },
|
|
||||||
// { value: 'anthropic', label: 'Anthropic (Claude)', defaultUrl: 'https://api.anthropic.com' },
|
|
||||||
{ value: 'gemini', label: 'Google Gemini', defaultUrl: 'https://generativelanguage.googleapis.com/v1beta' },
|
|
||||||
{
|
{
|
||||||
value: 'mumu',
|
value: 'mumu',
|
||||||
label: 'MuMuのAPI',
|
label: 'MuMuのAPI',
|
||||||
defaultUrl: 'https://api.mumuverse.space/v1',
|
defaultUrl: mumuTextDefaultUrl,
|
||||||
defaultModel: 'gemini-3-flash-preview'
|
defaultModel: 'gemini-3-flash-preview'
|
||||||
},
|
},
|
||||||
|
{ value: 'openai', label: 'OpenAI Compatible', defaultUrl: 'https://api.openai.com/v1' },
|
||||||
|
// { value: 'anthropic', label: 'Anthropic (Claude)', defaultUrl: 'https://api.anthropic.com' },
|
||||||
|
{ value: 'gemini', label: 'Google Gemini', defaultUrl: 'https://generativelanguage.googleapis.com/v1beta' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const selectedProvider = Form.useWatch('api_provider', form);
|
const selectedProvider = Form.useWatch('api_provider', form);
|
||||||
|
const selectedCoverProvider = Form.useWatch('cover_api_provider', form);
|
||||||
const selectedPresetProvider = Form.useWatch('api_provider', presetForm);
|
const selectedPresetProvider = Form.useWatch('api_provider', presetForm);
|
||||||
|
|
||||||
const handleProviderChange = (value: string) => {
|
const handleProviderChange = (value: string) => {
|
||||||
@@ -298,6 +313,83 @@ export default function SettingsPage() {
|
|||||||
setModelsFetched(false);
|
setModelsFetched(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const coverApiProviders = [
|
||||||
|
{
|
||||||
|
value: 'mumu',
|
||||||
|
label: 'MuMuのAPI',
|
||||||
|
defaultUrl: mumuCoverBaseUrlOptions[0].value,
|
||||||
|
defaultModel: mumuCoverBaseUrlOptions[0].defaultModel,
|
||||||
|
},
|
||||||
|
{ value: 'gemini', label: 'Google Gemini', defaultUrl: 'https://generativelanguage.googleapis.com/v1beta' },
|
||||||
|
{ value: 'grok', label: 'Grok', defaultUrl: 'https://api.x.ai/v1' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleCoverProviderChange = (value: string) => {
|
||||||
|
const provider = coverApiProviders.find(p => p.value === value);
|
||||||
|
if (!provider) {
|
||||||
|
setCoverTestResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValues: Record<string, string> = {};
|
||||||
|
if (provider.defaultUrl) {
|
||||||
|
nextValues.cover_api_base_url = provider.defaultUrl;
|
||||||
|
}
|
||||||
|
if (provider.value === 'mumu') {
|
||||||
|
nextValues.cover_api_key = '';
|
||||||
|
nextValues.cover_image_model = provider.defaultModel || mumuCoverBaseUrlOptions[0].defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setFieldsValue(nextValues);
|
||||||
|
setCoverTestResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMumuCoverBaseUrlChange = (value: string) => {
|
||||||
|
const option = mumuCoverBaseUrlOptions.find(item => item.value === value);
|
||||||
|
form.setFieldsValue({
|
||||||
|
cover_api_base_url: value,
|
||||||
|
cover_image_model: option?.defaultModel || mumuCoverBaseUrlOptions[0].defaultModel,
|
||||||
|
});
|
||||||
|
setCoverTestResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverTestConnection = async () => {
|
||||||
|
const coverApiProvider = form.getFieldValue('cover_api_provider');
|
||||||
|
const coverApiKey = form.getFieldValue('cover_api_key');
|
||||||
|
const coverApiBaseUrl = form.getFieldValue('cover_api_base_url');
|
||||||
|
const coverImageModel = form.getFieldValue('cover_image_model');
|
||||||
|
|
||||||
|
if (!coverApiProvider || !coverApiKey || !coverImageModel) {
|
||||||
|
message.warning('请先填写完整的封面图片配置信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestingCoverApi(true);
|
||||||
|
setCoverTestResult(null);
|
||||||
|
try {
|
||||||
|
const result = await settingsApi.testCoverConnection({
|
||||||
|
cover_api_provider: coverApiProvider,
|
||||||
|
cover_api_key: coverApiKey,
|
||||||
|
cover_api_base_url: coverApiBaseUrl,
|
||||||
|
cover_image_model: coverImageModel,
|
||||||
|
});
|
||||||
|
setCoverTestResult(result);
|
||||||
|
if (result.success) {
|
||||||
|
message.success('封面图片接口测试成功');
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '封面图片接口测试失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('封面图片接口测试失败:', error);
|
||||||
|
setCoverTestResult({
|
||||||
|
success: false,
|
||||||
|
message: '封面图片接口测试失败',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTestingCoverApi(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleFetchModels = async (silent: boolean = false) => {
|
const handleFetchModels = async (silent: boolean = false) => {
|
||||||
const apiKey = form.getFieldValue('api_key');
|
const apiKey = form.getFieldValue('api_key');
|
||||||
const apiBaseUrl = form.getFieldValue('api_base_url');
|
const apiBaseUrl = form.getFieldValue('api_base_url');
|
||||||
@@ -1002,7 +1094,7 @@ export default function SettingsPage() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'current',
|
key: 'current',
|
||||||
label: '当前配置',
|
label: <Space size={6}><ThunderboltOutlined />文本模型配置</Space>,
|
||||||
children: (
|
children: (
|
||||||
<Space direction="vertical" size={isMobile ? 'middle' : 'large'} style={{ width: '100%' }}>
|
<Space direction="vertical" size={isMobile ? 'middle' : 'large'} style={{ width: '100%' }}>
|
||||||
|
|
||||||
@@ -1079,7 +1171,7 @@ export default function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => window.open('https://api.mumuverse.space/register?aff=4NN8', '_blank', 'noopener,noreferrer')}
|
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')}
|
||||||
>
|
>
|
||||||
打开 MuMuのAPI 站点免费注册
|
打开 MuMuのAPI 站点免费注册
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1552,9 +1644,120 @@ export default function SettingsPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'cover',
|
||||||
|
label: <Space size={6}><PictureOutlined />图片模型配置</Space>,
|
||||||
|
children: (
|
||||||
|
<Spin spinning={initialLoading}>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleSave} autoComplete="off">
|
||||||
|
|
||||||
|
<Form.Item name="cover_enabled" valuePropName="checked" style={{ marginBottom: 16 }}>
|
||||||
|
<Select
|
||||||
|
size={isMobile ? 'middle' : 'large'}
|
||||||
|
onChange={(value) => form.setFieldsValue({ cover_enabled: value === 'enabled' })}
|
||||||
|
value={form.getFieldValue('cover_enabled') ? 'enabled' : 'disabled'}
|
||||||
|
options={[
|
||||||
|
{ value: 'enabled', label: '启用封面图片生成' },
|
||||||
|
{ value: 'disabled', label: '停用封面图片生成' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="封面图片 Provider" name="cover_api_provider" rules={[{ required: true, message: '请选择封面图片 Provider' }]}>
|
||||||
|
<Select size={isMobile ? 'middle' : 'large'} onChange={handleCoverProviderChange}>
|
||||||
|
{coverApiProviders.map(provider => (
|
||||||
|
<Option key={provider.value} value={provider.value}>{provider.label}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{selectedCoverProvider === 'mumu' && (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="MuMuのAPI 专属适配器"
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
|
<Text>
|
||||||
|
已固定提供 MuMuのAPI 图片接口地址选项,切换地址时会自动带出推荐模型。API Key 需前往 MuMuのAPI 站点注册获取。
|
||||||
|
</Text>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')}
|
||||||
|
>
|
||||||
|
打开 MuMuのAPI 站点免费注册
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Item label="封面图片 API Key" name="cover_api_key" rules={[{ required: true, message: '请输入封面图片 API Key' }]}>
|
||||||
|
<Input.Password size={isMobile ? 'middle' : 'large'} placeholder={selectedCoverProvider === 'mumu' ? '请输入 MuMuのAPI Key' : '输入封面图片 API Key'} autoComplete="new-password" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="封面图片 API 地址" name="cover_api_base_url" rules={[{ type: 'url', message: '请输入有效的URL' }]}>
|
||||||
|
{selectedCoverProvider === 'mumu' ? (
|
||||||
|
<Select
|
||||||
|
size={isMobile ? 'middle' : 'large'}
|
||||||
|
onChange={handleMumuCoverBaseUrlChange}
|
||||||
|
options={mumuCoverBaseUrlOptions.map(option => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input size={isMobile ? 'middle' : 'large'} placeholder={selectedCoverProvider === 'grok' ? 'https://api.x.ai/v1' : 'https://generativelanguage.googleapis.com/v1beta'} />
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="封面图片模型" name="cover_image_model" rules={[{ required: true, message: '请输入封面图片模型名称' }]}>
|
||||||
|
<Input
|
||||||
|
size={isMobile ? 'middle' : 'large'}
|
||||||
|
placeholder={selectedCoverProvider === 'mumu'
|
||||||
|
? '选择地址后自动填入推荐模型'
|
||||||
|
: selectedCoverProvider === 'grok'
|
||||||
|
? 'grok-2-image'
|
||||||
|
: 'gemini-2.0-flash-exp-image-generation'}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{coverTestResult && (
|
||||||
|
<Alert
|
||||||
|
type={coverTestResult.success ? 'success' : 'error'}
|
||||||
|
showIcon
|
||||||
|
message={coverTestResult.message}
|
||||||
|
description={coverTestResult.success ? `Provider: ${coverTestResult.provider || '-'} / Model: ${coverTestResult.model || '-'}` : undefined}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Item style={{ marginBottom: 0, marginTop: 24 }}>
|
||||||
|
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Space wrap>
|
||||||
|
<Button
|
||||||
|
icon={<ThunderboltOutlined />}
|
||||||
|
onClick={handleCoverTestConnection}
|
||||||
|
loading={testingCoverApi}
|
||||||
|
style={{ borderColor: token.colorSuccess, color: token.colorSuccess, fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{testingCoverApi ? '测试中...' : '测试封面接口'}
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
|
||||||
|
</Space>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} htmlType="submit" loading={loading}>保存封面配置</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Spin>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'presets',
|
key: 'presets',
|
||||||
label: '配置预设',
|
label: <Space size={6}><CopyOutlined />配置预设</Space>,
|
||||||
children: renderPresetsList(),
|
children: renderPresetsList(),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -1606,9 +1809,9 @@ export default function SettingsPage() {
|
|||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
>
|
>
|
||||||
<Select placeholder="选择提供商" onChange={handlePresetProviderChange}>
|
<Select placeholder="选择提供商" onChange={handlePresetProviderChange}>
|
||||||
|
<Select.Option value="mumu">MuMuのAPI</Select.Option>
|
||||||
<Select.Option value="openai">OpenAI</Select.Option>
|
<Select.Option value="openai">OpenAI</Select.Option>
|
||||||
<Select.Option value="gemini">Google Gemini</Select.Option>
|
<Select.Option value="gemini">Google Gemini</Select.Option>
|
||||||
<Select.Option value="mumu">MuMuのAPI</Select.Option>
|
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
@@ -1625,7 +1828,7 @@ export default function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => window.open('https://api.mumuverse.space/register?aff=4NN8', '_blank', 'noopener,noreferrer')}
|
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')}
|
||||||
>
|
>
|
||||||
打开 MuMuのAPI 站点免费注册
|
打开 MuMuのAPI 站点免费注册
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -218,6 +218,14 @@ export const settingsApi = {
|
|||||||
suggestions?: string[];
|
suggestions?: string[];
|
||||||
}>('/settings/test', params),
|
}>('/settings/test', params),
|
||||||
|
|
||||||
|
testCoverConnection: (params: { cover_api_provider: string; cover_api_key: string; cover_api_base_url?: string; cover_image_model: string }) =>
|
||||||
|
api.post<unknown, {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
}>('/settings/cover/test', params),
|
||||||
|
|
||||||
checkFunctionCalling: (params: { api_key: string; api_base_url: string; provider: string; llm_model: string }) =>
|
checkFunctionCalling: (params: { api_key: string; api_base_url: string; provider: string; llm_model: string }) =>
|
||||||
api.post<unknown, {
|
api.post<unknown, {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -296,6 +304,43 @@ export const projectApi = {
|
|||||||
|
|
||||||
deleteProject: (id: string) => api.delete(`/projects/${id}`),
|
deleteProject: (id: string) => api.delete(`/projects/${id}`),
|
||||||
|
|
||||||
|
generateCover: (id: string, overwrite: boolean = true) =>
|
||||||
|
api.post<unknown, {
|
||||||
|
project_id: string;
|
||||||
|
cover_status: string;
|
||||||
|
cover_image_url?: string;
|
||||||
|
cover_prompt?: string;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
message: string;
|
||||||
|
}>(`/projects/${id}/cover/generate`, { overwrite }),
|
||||||
|
|
||||||
|
downloadCover: async (id: string, filename?: string) => {
|
||||||
|
const response = await axios.get(`/api/projects/${id}/cover/download`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
const contentDisposition = response.headers['content-disposition'];
|
||||||
|
let finalFilename = filename || 'novel-cover.png';
|
||||||
|
if (contentDisposition) {
|
||||||
|
const utf8Match = /filename\*=UTF-8''(.+)/.exec(contentDisposition);
|
||||||
|
const basicMatch = /filename="?([^";]+)"?/.exec(contentDisposition);
|
||||||
|
if (utf8Match?.[1]) {
|
||||||
|
finalFilename = decodeURIComponent(utf8Match[1]);
|
||||||
|
} else if (basicMatch?.[1]) {
|
||||||
|
finalFilename = basicMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', finalFilename);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
|
||||||
exportProject: (id: string) => {
|
exportProject: (id: string) => {
|
||||||
window.open(`/api/projects/${id}/export`, '_blank');
|
window.open(`/api/projects/${id}/export`, '_blank');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ export interface Settings {
|
|||||||
temperature: number;
|
temperature: number;
|
||||||
max_tokens: number;
|
max_tokens: number;
|
||||||
system_prompt?: string;
|
system_prompt?: string;
|
||||||
|
cover_api_provider?: string;
|
||||||
|
cover_api_key?: string;
|
||||||
|
cover_api_base_url?: string;
|
||||||
|
cover_image_model?: string;
|
||||||
|
cover_enabled?: boolean;
|
||||||
preferences?: string;
|
preferences?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -35,6 +40,11 @@ export interface SettingsUpdate {
|
|||||||
temperature?: number;
|
temperature?: number;
|
||||||
max_tokens?: number;
|
max_tokens?: number;
|
||||||
system_prompt?: string;
|
system_prompt?: string;
|
||||||
|
cover_api_provider?: string;
|
||||||
|
cover_api_key?: string;
|
||||||
|
cover_api_base_url?: string;
|
||||||
|
cover_image_model?: string;
|
||||||
|
cover_enabled?: boolean;
|
||||||
preferences?: string;
|
preferences?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +112,11 @@ export interface Project {
|
|||||||
chapter_count?: number;
|
chapter_count?: number;
|
||||||
narrative_perspective?: string;
|
narrative_perspective?: string;
|
||||||
character_count?: number;
|
character_count?: number;
|
||||||
|
cover_image_url?: string;
|
||||||
|
cover_prompt?: string;
|
||||||
|
cover_status?: 'none' | 'generating' | 'ready' | 'failed';
|
||||||
|
cover_error?: string;
|
||||||
|
cover_updated_at?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8000',
|
target: 'http://localhost:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/generated-assets': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user