feat: 初始提交
This commit is contained in:
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
12
backend/app/api/routers/__init__.py
Normal file
12
backend/app/api/routers/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import admin, auth, llm_config, novels, updates, writer
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(novels.router)
|
||||
api_router.include_router(writer.router)
|
||||
api_router.include_router(admin.router)
|
||||
api_router.include_router(updates.router)
|
||||
api_router.include_router(llm_config.router)
|
||||
340
backend/app/api/routers/admin.py
Normal file
340
backend/app/api/routers/admin.py
Normal file
@@ -0,0 +1,340 @@
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.dependencies import get_current_admin
|
||||
from ...db.session import get_session
|
||||
from ...models import NovelProject, UsageMetric, User
|
||||
from ...schemas.admin import (
|
||||
AdminNovelSummary,
|
||||
DailyRequestLimit,
|
||||
Statistics,
|
||||
UpdateLogCreate,
|
||||
UpdateLogRead,
|
||||
UpdateLogUpdate,
|
||||
)
|
||||
from ...schemas.config import SystemConfigCreate, SystemConfigRead, SystemConfigUpdate
|
||||
from ...schemas.prompt import PromptCreate, PromptRead, PromptUpdate
|
||||
from ...schemas.novel import (
|
||||
Chapter as ChapterSchema,
|
||||
NovelProject as NovelProjectSchema,
|
||||
NovelSectionResponse,
|
||||
NovelSectionType,
|
||||
)
|
||||
from ...schemas.user import PasswordChangeRequest, User as UserSchema
|
||||
from ...services.auth_service import AuthService
|
||||
from ...services.admin_setting_service import AdminSettingService
|
||||
from ...services.config_service import ConfigService
|
||||
from ...services.novel_service import NovelService
|
||||
from ...services.prompt_service import PromptService
|
||||
from ...services.update_log_service import UpdateLogService
|
||||
from ...services.user_service import UserService
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["Admin"])
|
||||
|
||||
|
||||
def get_prompt_service(session: AsyncSession = Depends(get_session)) -> PromptService:
|
||||
return PromptService(session)
|
||||
|
||||
|
||||
def get_update_log_service(session: AsyncSession = Depends(get_session)) -> UpdateLogService:
|
||||
return UpdateLogService(session)
|
||||
|
||||
|
||||
def get_admin_setting_service(session: AsyncSession = Depends(get_session)) -> AdminSettingService:
|
||||
return AdminSettingService(session)
|
||||
|
||||
|
||||
def get_config_service(session: AsyncSession = Depends(get_session)) -> ConfigService:
|
||||
return ConfigService(session)
|
||||
|
||||
|
||||
def get_novel_service(session: AsyncSession = Depends(get_session)) -> NovelService:
|
||||
return NovelService(session)
|
||||
|
||||
|
||||
def get_user_service(session: AsyncSession = Depends(get_session)) -> UserService:
|
||||
return UserService(session)
|
||||
|
||||
|
||||
def get_auth_service(session: AsyncSession = Depends(get_session)) -> AuthService:
|
||||
return AuthService(session)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=Statistics)
|
||||
async def read_statistics(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> Statistics:
|
||||
novel_count = await session.scalar(select(func.count(NovelProject.id))) or 0
|
||||
user_count = await session.scalar(select(func.count(User.id))) or 0
|
||||
usage = await session.get(UsageMetric, "api_request_count")
|
||||
api_request_count = usage.value if usage else 0
|
||||
logger.info("管理员获取统计数据:小说=%s,用户=%s,请求=%s", novel_count, user_count, api_request_count)
|
||||
return Statistics(novel_count=novel_count, user_count=user_count, api_request_count=api_request_count)
|
||||
|
||||
|
||||
@router.get("/users", response_model=List[UserSchema])
|
||||
async def list_users(
|
||||
service: UserService = Depends(get_user_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> List[UserSchema]:
|
||||
users = await service.list_users()
|
||||
logger.info("管理员请求用户列表,共 %s 条", len(users))
|
||||
return [UserSchema.model_validate(user) for user in users]
|
||||
|
||||
|
||||
@router.get("/novel-projects", response_model=List[AdminNovelSummary])
|
||||
async def list_novel_projects(
|
||||
service: NovelService = Depends(get_novel_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> List[AdminNovelSummary]:
|
||||
projects = await service.list_projects_for_admin()
|
||||
logger.info("管理员查看项目列表,共 %s 个", len(projects))
|
||||
return projects
|
||||
|
||||
|
||||
@router.get("/novel-projects/{project_id}", response_model=NovelProjectSchema)
|
||||
async def get_novel_project(
|
||||
project_id: str,
|
||||
service: NovelService = Depends(get_novel_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> NovelProjectSchema:
|
||||
logger.info("管理员查看项目详情:%s", project_id)
|
||||
return await service.get_project_schema_for_admin(project_id)
|
||||
|
||||
|
||||
@router.get("/novel-projects/{project_id}/sections/{section}", response_model=NovelSectionResponse)
|
||||
async def get_novel_project_section(
|
||||
project_id: str,
|
||||
section: NovelSectionType,
|
||||
service: NovelService = Depends(get_novel_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> NovelSectionResponse:
|
||||
logger.info("管理员查看项目 %s 的 %s 区段", project_id, section)
|
||||
return await service.get_section_data_for_admin(project_id, section)
|
||||
|
||||
|
||||
@router.get("/novel-projects/{project_id}/chapters/{chapter_number}", response_model=ChapterSchema)
|
||||
async def get_novel_project_chapter(
|
||||
project_id: str,
|
||||
chapter_number: int,
|
||||
service: NovelService = Depends(get_novel_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> ChapterSchema:
|
||||
logger.info("管理员查看项目 %s 第 %s 章详情", project_id, chapter_number)
|
||||
return await service.get_chapter_schema_for_admin(project_id, chapter_number)
|
||||
|
||||
|
||||
@router.get("/prompts", response_model=List[PromptRead])
|
||||
async def list_prompts(
|
||||
service: PromptService = Depends(get_prompt_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> List[PromptRead]:
|
||||
prompts = await service.list_prompts()
|
||||
logger.info("管理员请求提示词列表,共 %s 条", len(prompts))
|
||||
return prompts
|
||||
|
||||
|
||||
@router.post("/prompts", response_model=PromptRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_prompt(
|
||||
payload: PromptCreate,
|
||||
service: PromptService = Depends(get_prompt_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> PromptRead:
|
||||
prompt = await service.create_prompt(payload)
|
||||
logger.info("管理员创建提示词:%s", prompt.id)
|
||||
return prompt
|
||||
|
||||
|
||||
@router.get("/prompts/{prompt_id}", response_model=PromptRead)
|
||||
async def get_prompt(
|
||||
prompt_id: int,
|
||||
service: PromptService = Depends(get_prompt_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> PromptRead:
|
||||
prompt = await service.get_prompt_by_id(prompt_id)
|
||||
if not prompt:
|
||||
logger.warning("提示词 %s 不存在", prompt_id)
|
||||
raise HTTPException(status_code=404, detail="提示词不存在")
|
||||
logger.info("管理员获取提示词:%s", prompt_id)
|
||||
return prompt
|
||||
|
||||
|
||||
@router.patch("/prompts/{prompt_id}", response_model=PromptRead)
|
||||
async def update_prompt(
|
||||
prompt_id: int,
|
||||
payload: PromptUpdate,
|
||||
service: PromptService = Depends(get_prompt_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> PromptRead:
|
||||
result = await service.update_prompt(prompt_id, payload)
|
||||
if not result:
|
||||
logger.warning("提示词 %s 不存在,无法更新", prompt_id)
|
||||
raise HTTPException(status_code=404, detail="提示词不存在")
|
||||
logger.info("管理员更新提示词:%s", prompt_id)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/prompts/{prompt_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_prompt(
|
||||
prompt_id: int,
|
||||
service: PromptService = Depends(get_prompt_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> None:
|
||||
deleted = await service.delete_prompt(prompt_id)
|
||||
if not deleted:
|
||||
logger.warning("提示词 %s 不存在,无法删除", prompt_id)
|
||||
raise HTTPException(status_code=404, detail="提示词不存在")
|
||||
logger.info("管理员删除提示词:%s", prompt_id)
|
||||
|
||||
|
||||
@router.get("/update-logs", response_model=List[UpdateLogRead])
|
||||
async def list_update_logs(
|
||||
service: UpdateLogService = Depends(get_update_log_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> List[UpdateLogRead]:
|
||||
logs = await service.list_logs()
|
||||
logger.info("管理员查看更新日志列表,共 %s 条", len(logs))
|
||||
return [UpdateLogRead.model_validate(log) for log in logs]
|
||||
|
||||
|
||||
@router.post("/update-logs", response_model=UpdateLogRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_update_log(
|
||||
payload: UpdateLogCreate,
|
||||
service: UpdateLogService = Depends(get_update_log_service),
|
||||
current_admin=Depends(get_current_admin),
|
||||
) -> UpdateLogRead:
|
||||
log = await service.create_log(
|
||||
payload.content,
|
||||
creator=current_admin.username,
|
||||
is_pinned=payload.is_pinned or False,
|
||||
)
|
||||
logger.info("管理员 %s 创建更新日志:%s", current_admin.username, log.id)
|
||||
return UpdateLogRead.model_validate(log)
|
||||
|
||||
|
||||
@router.delete("/update-logs/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_update_log(
|
||||
log_id: int,
|
||||
service: UpdateLogService = Depends(get_update_log_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> None:
|
||||
await service.delete_log(log_id)
|
||||
logger.info("管理员删除更新日志:%s", log_id)
|
||||
|
||||
|
||||
@router.patch("/update-logs/{log_id}", response_model=UpdateLogRead)
|
||||
async def update_update_log(
|
||||
log_id: int,
|
||||
payload: UpdateLogUpdate,
|
||||
service: UpdateLogService = Depends(get_update_log_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> UpdateLogRead:
|
||||
log = await service.update_log(
|
||||
log_id,
|
||||
content=payload.content,
|
||||
is_pinned=payload.is_pinned,
|
||||
)
|
||||
logger.info("管理员更新日志 %s", log_id)
|
||||
return UpdateLogRead.model_validate(log)
|
||||
|
||||
|
||||
@router.get("/settings/daily-request-limit", response_model=DailyRequestLimit)
|
||||
async def get_daily_limit(
|
||||
service: AdminSettingService = Depends(get_admin_setting_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> DailyRequestLimit:
|
||||
value = await service.get("daily_request_limit", "100")
|
||||
logger.info("管理员查询每日请求上限:%s", value)
|
||||
return DailyRequestLimit(limit=int(value or 100))
|
||||
|
||||
|
||||
@router.put("/settings/daily-request-limit", response_model=DailyRequestLimit)
|
||||
async def update_daily_limit(
|
||||
payload: DailyRequestLimit,
|
||||
service: AdminSettingService = Depends(get_admin_setting_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> DailyRequestLimit:
|
||||
await service.set("daily_request_limit", str(payload.limit))
|
||||
logger.info("管理员设置每日请求上限为 %s", payload.limit)
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/system-configs", response_model=List[SystemConfigRead])
|
||||
async def list_system_configs(
|
||||
service: ConfigService = Depends(get_config_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> List[SystemConfigRead]:
|
||||
configs = await service.list_configs()
|
||||
logger.info("管理员获取系统配置,共 %s 条", len(configs))
|
||||
return configs
|
||||
|
||||
|
||||
@router.get("/system-configs/{key}", response_model=SystemConfigRead)
|
||||
async def get_system_config(
|
||||
key: str,
|
||||
service: ConfigService = Depends(get_config_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> SystemConfigRead:
|
||||
config = await service.get_config(key)
|
||||
if not config:
|
||||
logger.warning("系统配置 %s 不存在", key)
|
||||
raise HTTPException(status_code=404, detail="配置项不存在")
|
||||
logger.info("管理员查询系统配置:%s", key)
|
||||
return config
|
||||
|
||||
|
||||
@router.put("/system-configs/{key}", response_model=SystemConfigRead)
|
||||
async def upsert_system_config(
|
||||
key: str,
|
||||
payload: SystemConfigCreate,
|
||||
service: ConfigService = Depends(get_config_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> SystemConfigRead:
|
||||
logger.info("管理员写入系统配置:%s", key)
|
||||
return await service.upsert_config(
|
||||
SystemConfigCreate(key=key, value=payload.value, description=payload.description)
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/system-configs/{key}", response_model=SystemConfigRead)
|
||||
async def patch_system_config(
|
||||
key: str,
|
||||
payload: SystemConfigUpdate,
|
||||
service: ConfigService = Depends(get_config_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> SystemConfigRead:
|
||||
config = await service.patch_config(key, payload)
|
||||
if not config:
|
||||
logger.warning("系统配置 %s 不存在,无法更新", key)
|
||||
raise HTTPException(status_code=404, detail="配置项不存在")
|
||||
logger.info("管理员部分更新系统配置:%s", key)
|
||||
return config
|
||||
|
||||
|
||||
@router.delete("/system-configs/{key}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_system_config(
|
||||
key: str,
|
||||
service: ConfigService = Depends(get_config_service),
|
||||
_: None = Depends(get_current_admin),
|
||||
) -> None:
|
||||
deleted = await service.remove_config(key)
|
||||
if not deleted:
|
||||
logger.warning("系统配置 %s 不存在,无法删除", key)
|
||||
raise HTTPException(status_code=404, detail="配置项不存在")
|
||||
logger.info("管理员删除系统配置:%s", key)
|
||||
|
||||
|
||||
@router.post("/password", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def change_password(
|
||||
payload: PasswordChangeRequest,
|
||||
current_admin=Depends(get_current_admin),
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> None:
|
||||
await service.change_password(current_admin.username, payload.old_password, payload.new_password)
|
||||
logger.info("管理员 %s 修改密码", current_admin.username)
|
||||
106
backend/app/api/routers/auth.py
Normal file
106
backend/app/api/routers/auth.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.config import settings
|
||||
from ...core.dependencies import get_current_user
|
||||
from ...db.session import get_session
|
||||
from ...schemas.user import AuthOptions, Token, User, UserInDB, UserRegistration
|
||||
from ...services.auth_service import AuthService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
|
||||
|
||||
def get_auth_service(session: AsyncSession = Depends(get_session)) -> AuthService:
|
||||
return AuthService(session)
|
||||
|
||||
|
||||
@router.post("/send-code", status_code=204)
|
||||
async def send_verification_code(email: str, service: AuthService = Depends(get_auth_service)):
|
||||
await service.send_verification_code(email)
|
||||
logger.info("向 %s 发送验证码", email)
|
||||
|
||||
|
||||
@router.get("/options", response_model=AuthOptions)
|
||||
async def read_auth_options(service: AuthService = Depends(get_auth_service)):
|
||||
"""读取认证功能开关,供前端动态渲染。"""
|
||||
options = await service.get_auth_options()
|
||||
return options
|
||||
|
||||
|
||||
@router.post("/users", response_model=User, status_code=status.HTTP_201_CREATED)
|
||||
async def register_user(payload: UserRegistration, service: AuthService = Depends(get_auth_service)):
|
||||
user = await service.register_user(payload)
|
||||
logger.info("注册新用户:%s", user.username)
|
||||
return User.model_validate(user)
|
||||
|
||||
|
||||
@router.post("/token", response_model=Token)
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends(), service: AuthService = Depends(get_auth_service)):
|
||||
user = await service.authenticate_user(form_data.username, form_data.password)
|
||||
must_change_password = service.requires_password_reset(user)
|
||||
token = await service.create_access_token(user, must_change_password=must_change_password)
|
||||
logger.info("用户 %s 登录成功,需改密=%s", form_data.username, must_change_password)
|
||||
return token
|
||||
|
||||
|
||||
@router.get("/users/me", response_model=User)
|
||||
async def read_current_user(current_user: UserInDB = Depends(get_current_user)):
|
||||
logger.debug("读取当前用户:%s", current_user.username)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/linuxdo/login")
|
||||
async def login_with_linuxdo(service: AuthService = Depends(get_auth_service)):
|
||||
if not await service.is_linuxdo_login_enabled():
|
||||
logger.warning("Linux.do 登录未启用")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未启用 Linux.do 登录")
|
||||
client_id = await service.get_config_value("linuxdo.client_id")
|
||||
redirect_uri = await service.get_config_value("linuxdo.redirect_uri")
|
||||
auth_url = await service.get_config_value("linuxdo.auth_url")
|
||||
if not all([client_id, redirect_uri, auth_url]):
|
||||
logger.error("Linux.do OAuth 参数未配置完整")
|
||||
raise HTTPException(status_code=500, detail="未配置 Linux.do OAuth 参数")
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "user",
|
||||
}
|
||||
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
logger.info("跳转 Linux.do 授权,client_id=%s", client_id)
|
||||
return RedirectResponse(url=f"{auth_url}?{query}")
|
||||
|
||||
|
||||
@router.get("/linuxdo/register", response_class=HTMLResponse)
|
||||
async def register_with_linuxdo(code: str, service: AuthService = Depends(get_auth_service)):
|
||||
token = await service.handle_linuxdo_callback(code)
|
||||
logger.info("Linux.do 授权回调成功")
|
||||
token_json = token.model_dump_json()
|
||||
html_content = f"""<!DOCTYPE html>
|
||||
<html lang=\"zh-CN\">
|
||||
<head><meta charset=\"UTF-8\"><title>正在跳转</title></head>
|
||||
<body>
|
||||
<p>正在跳转,请稍候...</p>
|
||||
<script>
|
||||
(function() {{
|
||||
const token = JSON.parse('{token_json}');
|
||||
try {{
|
||||
window.localStorage.setItem('token', token.access_token);
|
||||
}} catch (err) {{
|
||||
console.error('无法写入本地存储', err);
|
||||
}}
|
||||
window.location.replace('/');
|
||||
}})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=html_content)
|
||||
54
backend/app/api/routers/llm_config.py
Normal file
54
backend/app/api/routers/llm_config.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.dependencies import get_current_user
|
||||
from ...db.session import get_session
|
||||
from ...schemas.llm_config import LLMConfigCreate, LLMConfigRead
|
||||
from ...schemas.user import UserInDB
|
||||
from ...services.llm_config_service import LLMConfigService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/llm-config", tags=["LLM Configuration"])
|
||||
|
||||
|
||||
def get_llm_config_service(session: AsyncSession = Depends(get_session)) -> LLMConfigService:
|
||||
return LLMConfigService(session)
|
||||
|
||||
|
||||
@router.get("", response_model=LLMConfigRead)
|
||||
async def read_llm_config(
|
||||
service: LLMConfigService = Depends(get_llm_config_service),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> LLMConfigRead:
|
||||
config = await service.get_config(current_user.id)
|
||||
if not config:
|
||||
logger.warning("用户 %s 尚未设置 LLM 配置", current_user.id)
|
||||
raise HTTPException(status_code=404, detail="尚未设置自定义配置")
|
||||
logger.info("用户 %s 获取 LLM 配置", current_user.id)
|
||||
return config
|
||||
|
||||
|
||||
@router.put("", response_model=LLMConfigRead)
|
||||
async def upsert_llm_config(
|
||||
payload: LLMConfigCreate,
|
||||
service: LLMConfigService = Depends(get_llm_config_service),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> LLMConfigRead:
|
||||
logger.info("用户 %s 更新 LLM 配置", current_user.id)
|
||||
return await service.upsert_config(current_user.id, payload)
|
||||
|
||||
|
||||
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_llm_config(
|
||||
service: LLMConfigService = Depends(get_llm_config_service),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> None:
|
||||
deleted = await service.delete_config(current_user.id)
|
||||
if not deleted:
|
||||
logger.warning("用户 %s 删除 LLM 配置失败,未找到记录", current_user.id)
|
||||
raise HTTPException(status_code=404, detail="未找到配置")
|
||||
logger.info("用户 %s 删除 LLM 配置", current_user.id)
|
||||
301
backend/app/api/routers/novels.py
Normal file
301
backend/app/api/routers/novels.py
Normal file
@@ -0,0 +1,301 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.dependencies import get_current_user
|
||||
from ...db.session import get_session
|
||||
from ...schemas.novel import (
|
||||
Blueprint,
|
||||
BlueprintGenerationResponse,
|
||||
BlueprintPatch,
|
||||
Chapter as ChapterSchema,
|
||||
ConverseRequest,
|
||||
ConverseResponse,
|
||||
NovelProject as NovelProjectSchema,
|
||||
NovelProjectSummary,
|
||||
NovelSectionResponse,
|
||||
NovelSectionType,
|
||||
)
|
||||
from ...schemas.user import UserInDB
|
||||
from ...services.llm_service import LLMService
|
||||
from ...services.novel_service import NovelService
|
||||
from ...services.prompt_service import PromptService
|
||||
from ...utils.json_utils import remove_think_tags, unwrap_markdown_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/novels", tags=["Novels"])
|
||||
|
||||
JSON_RESPONSE_INSTRUCTION = """
|
||||
IMPORTANT: 你的回复必须是合法的 JSON 对象,并严格包含以下字段:
|
||||
{
|
||||
"ai_message": "string",
|
||||
"ui_control": {
|
||||
"type": "single_choice | text_input | info_display",
|
||||
"options": [
|
||||
{"id": "option_1", "label": "string"}
|
||||
],
|
||||
"placeholder": "string"
|
||||
},
|
||||
"conversation_state": {},
|
||||
"is_complete": false
|
||||
}
|
||||
不要输出额外的文本或解释。
|
||||
"""
|
||||
|
||||
|
||||
def _ensure_prompt(prompt: str | None, name: str) -> str:
|
||||
if not prompt:
|
||||
raise HTTPException(status_code=500, detail=f"未配置名为 {name} 的提示词,请联系管理员")
|
||||
return prompt
|
||||
|
||||
|
||||
@router.post("", response_model=NovelProjectSchema, status_code=status.HTTP_201_CREATED)
|
||||
async def create_novel(
|
||||
title: str = Body(...),
|
||||
initial_prompt: str = Body(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
"""为当前用户创建一个新的小说项目。"""
|
||||
novel_service = NovelService(session)
|
||||
project = await novel_service.create_project(current_user.id, title, initial_prompt)
|
||||
logger.info("用户 %s 创建项目 %s", current_user.id, project.id)
|
||||
return await novel_service.get_project_schema(project.id, current_user.id)
|
||||
|
||||
|
||||
@router.get("", response_model=List[NovelProjectSummary])
|
||||
async def list_novels(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> List[NovelProjectSummary]:
|
||||
"""列出用户的全部小说项目摘要信息。"""
|
||||
novel_service = NovelService(session)
|
||||
projects = await novel_service.list_projects_for_user(current_user.id)
|
||||
logger.info("用户 %s 获取项目列表,共 %s 个", current_user.id, len(projects))
|
||||
return projects
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=NovelProjectSchema)
|
||||
async def get_novel(
|
||||
project_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
novel_service = NovelService(session)
|
||||
logger.info("用户 %s 查询项目 %s", current_user.id, project_id)
|
||||
return await novel_service.get_project_schema(project_id, current_user.id)
|
||||
|
||||
|
||||
@router.get("/{project_id}/sections/{section}", response_model=NovelSectionResponse)
|
||||
async def get_novel_section(
|
||||
project_id: str,
|
||||
section: NovelSectionType,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelSectionResponse:
|
||||
novel_service = NovelService(session)
|
||||
logger.info("用户 %s 获取项目 %s 的 %s 区段", current_user.id, project_id, section)
|
||||
return await novel_service.get_section_data(project_id, current_user.id, section)
|
||||
|
||||
|
||||
@router.get("/{project_id}/chapters/{chapter_number}", response_model=ChapterSchema)
|
||||
async def get_chapter(
|
||||
project_id: str,
|
||||
chapter_number: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> ChapterSchema:
|
||||
novel_service = NovelService(session)
|
||||
logger.info("用户 %s 获取项目 %s 第 %s 章", current_user.id, project_id, chapter_number)
|
||||
return await novel_service.get_chapter_schema(project_id, current_user.id, chapter_number)
|
||||
|
||||
|
||||
@router.delete("", status_code=status.HTTP_200_OK)
|
||||
async def delete_novels(
|
||||
project_ids: List[str] = Body(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> Dict[str, str]:
|
||||
novel_service = NovelService(session)
|
||||
await novel_service.delete_projects(project_ids, current_user.id)
|
||||
logger.info("用户 %s 删除项目 %s", current_user.id, project_ids)
|
||||
return {"status": "success", "message": f"成功删除 {len(project_ids)} 个项目"}
|
||||
|
||||
|
||||
@router.post("/{project_id}/concept/converse", response_model=ConverseResponse)
|
||||
async def converse_with_concept(
|
||||
project_id: str,
|
||||
request: ConverseRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> ConverseResponse:
|
||||
"""与概念设计师(LLM)进行对话,引导蓝图筹备。"""
|
||||
novel_service = NovelService(session)
|
||||
prompt_service = PromptService(session)
|
||||
llm_service = LLMService(session)
|
||||
|
||||
project = await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
|
||||
history_records = await novel_service.list_conversations(project_id)
|
||||
logger.info(
|
||||
"项目 %s 概念对话请求,用户 %s,历史记录 %s 条",
|
||||
project_id,
|
||||
current_user.id,
|
||||
len(history_records),
|
||||
)
|
||||
conversation_history = [
|
||||
{"role": record.role, "content": record.content}
|
||||
for record in history_records
|
||||
]
|
||||
user_content = json.dumps(request.user_input, ensure_ascii=False)
|
||||
conversation_history.append({"role": "user", "content": user_content})
|
||||
|
||||
system_prompt = _ensure_prompt(await prompt_service.get_prompt("concept"), "concept")
|
||||
system_prompt = f"{system_prompt}\n{JSON_RESPONSE_INSTRUCTION}"
|
||||
|
||||
llm_response = await llm_service.get_llm_response(
|
||||
system_prompt=system_prompt,
|
||||
conversation_history=conversation_history,
|
||||
temperature=0.8,
|
||||
user_id=current_user.id,
|
||||
timeout=240.0,
|
||||
)
|
||||
llm_response = remove_think_tags(llm_response)
|
||||
|
||||
try:
|
||||
normalized = unwrap_markdown_json(llm_response)
|
||||
parsed = json.loads(normalized)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.exception(
|
||||
"Failed to parse concept converse response: project_id=%s user_id=%s normalized=%s",
|
||||
project_id,
|
||||
current_user.id,
|
||||
normalized,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="AI 返回内容不是有效的 JSON") from exc
|
||||
|
||||
await novel_service.append_conversation(project_id, "user", user_content)
|
||||
await novel_service.append_conversation(project_id, "assistant", normalized)
|
||||
|
||||
logger.info("项目 %s 概念对话完成,is_complete=%s", project_id, parsed.get("is_complete"))
|
||||
|
||||
if parsed.get("is_complete"):
|
||||
parsed["ready_for_blueprint"] = True
|
||||
|
||||
parsed.setdefault("conversation_state", parsed.get("conversation_state", {}))
|
||||
return ConverseResponse(**parsed)
|
||||
|
||||
|
||||
@router.post("/{project_id}/blueprint/generate", response_model=BlueprintGenerationResponse)
|
||||
async def generate_blueprint(
|
||||
project_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> BlueprintGenerationResponse:
|
||||
"""根据完整对话生成可执行的小说蓝图。"""
|
||||
novel_service = NovelService(session)
|
||||
prompt_service = PromptService(session)
|
||||
llm_service = LLMService(session)
|
||||
|
||||
project = await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
logger.info("项目 %s 开始生成蓝图", project_id)
|
||||
|
||||
history_records = await novel_service.list_conversations(project_id)
|
||||
if not history_records:
|
||||
raise HTTPException(status_code=400, detail="缺少对话历史,无法生成蓝图")
|
||||
|
||||
formatted_history: List[Dict[str, str]] = []
|
||||
for record in history_records:
|
||||
role = record.role
|
||||
content = record.content
|
||||
if not role or not content:
|
||||
continue
|
||||
try:
|
||||
normalized = unwrap_markdown_json(content)
|
||||
data = json.loads(normalized)
|
||||
if role == "user":
|
||||
user_value = data.get("value", data)
|
||||
if isinstance(user_value, str):
|
||||
formatted_history.append({"role": "user", "content": user_value})
|
||||
elif role == "assistant":
|
||||
ai_message = data.get("ai_message") if isinstance(data, dict) else None
|
||||
if ai_message:
|
||||
formatted_history.append({"role": "assistant", "content": ai_message})
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
continue
|
||||
|
||||
if not formatted_history:
|
||||
raise HTTPException(status_code=400, detail="无法从历史对话中提取内容")
|
||||
|
||||
system_prompt = _ensure_prompt(await prompt_service.get_prompt("screenwriting"), "screenwriting")
|
||||
blueprint_raw = await llm_service.get_llm_response(
|
||||
system_prompt=system_prompt,
|
||||
conversation_history=formatted_history,
|
||||
temperature=0.3,
|
||||
user_id=current_user.id,
|
||||
timeout=480.0,
|
||||
)
|
||||
blueprint_raw = remove_think_tags(blueprint_raw)
|
||||
|
||||
blueprint_normalized = unwrap_markdown_json(blueprint_raw)
|
||||
try:
|
||||
blueprint_data = json.loads(blueprint_normalized)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=500, detail="蓝图生成失败,请稍后重试") from exc
|
||||
|
||||
blueprint = Blueprint(**blueprint_data)
|
||||
await novel_service.replace_blueprint(project_id, blueprint)
|
||||
if blueprint.title:
|
||||
project.title = blueprint.title
|
||||
project.status = "blueprint_ready"
|
||||
await session.commit()
|
||||
logger.info("项目 %s 更新标题为 %s,并标记为 blueprint_ready", project_id, blueprint.title)
|
||||
|
||||
ai_message = (
|
||||
"太棒了!我已经根据我们的对话整理出完整的小说蓝图。请确认是否进入写作阶段,或提出修改意见。"
|
||||
)
|
||||
return BlueprintGenerationResponse(blueprint=blueprint, ai_message=ai_message)
|
||||
|
||||
|
||||
@router.post("/{project_id}/blueprint/save", response_model=NovelProjectSchema)
|
||||
async def save_blueprint(
|
||||
project_id: str,
|
||||
blueprint_data: Blueprint | None = Body(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
"""保存蓝图信息,可用于手动覆盖自动生成结果。"""
|
||||
novel_service = NovelService(session)
|
||||
project = await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
|
||||
if blueprint_data:
|
||||
await novel_service.replace_blueprint(project_id, blueprint_data)
|
||||
if blueprint_data.title:
|
||||
project.title = blueprint_data.title
|
||||
await session.commit()
|
||||
logger.info("项目 %s 手动保存蓝图", project_id)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="缺少蓝图数据")
|
||||
|
||||
return await novel_service.get_project_schema(project_id, current_user.id)
|
||||
|
||||
|
||||
@router.patch("/{project_id}/blueprint", response_model=NovelProjectSchema)
|
||||
async def patch_blueprint(
|
||||
project_id: str,
|
||||
payload: BlueprintPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
"""局部更新蓝图字段,对世界观或角色做微调。"""
|
||||
novel_service = NovelService(session)
|
||||
project = await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
await novel_service.patch_blueprint(project_id, update_data)
|
||||
logger.info("项目 %s 局部更新蓝图字段:%s", project_id, list(update_data.keys()))
|
||||
return await novel_service.get_project_schema(project_id, current_user.id)
|
||||
22
backend/app/api/routers/updates.py
Normal file
22
backend/app/api/routers/updates.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...db.session import get_session
|
||||
from ...schemas.admin import UpdateLogRead
|
||||
from ...services.update_log_service import UpdateLogService
|
||||
|
||||
router = APIRouter(prefix="/api/updates", tags=["Updates"])
|
||||
|
||||
|
||||
def get_update_log_service(session: AsyncSession = Depends(get_session)) -> UpdateLogService:
|
||||
return UpdateLogService(session)
|
||||
|
||||
|
||||
@router.get("/latest", response_model=List[UpdateLogRead])
|
||||
async def read_latest_updates(
|
||||
service: UpdateLogService = Depends(get_update_log_service),
|
||||
) -> List[UpdateLogRead]:
|
||||
logs = await service.list_logs(limit=5)
|
||||
return [UpdateLogRead.model_validate(log) for log in logs]
|
||||
613
backend/app/api/routers/writer.py
Normal file
613
backend/app/api/routers/writer.py
Normal file
@@ -0,0 +1,613 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.config import settings
|
||||
from ...core.dependencies import get_current_user
|
||||
from ...db.session import get_session
|
||||
from ...models.novel import Chapter, ChapterOutline
|
||||
from ...schemas.novel import (
|
||||
DeleteChapterRequest,
|
||||
EditChapterRequest,
|
||||
EvaluateChapterRequest,
|
||||
GenerateChapterRequest,
|
||||
GenerateOutlineRequest,
|
||||
NovelProject as NovelProjectSchema,
|
||||
SelectVersionRequest,
|
||||
UpdateChapterOutlineRequest,
|
||||
)
|
||||
from ...schemas.user import UserInDB
|
||||
from ...services.chapter_context_service import ChapterContextService
|
||||
from ...services.chapter_ingest_service import ChapterIngestionService
|
||||
from ...services.llm_service import LLMService
|
||||
from ...services.novel_service import NovelService
|
||||
from ...services.prompt_service import PromptService
|
||||
from ...services.vector_store_service import VectorStoreService
|
||||
from ...utils.json_utils import remove_think_tags, unwrap_markdown_json
|
||||
from ...repositories.system_config_repository import SystemConfigRepository
|
||||
|
||||
router = APIRouter(prefix="/api/writer", tags=["Writer"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _load_project_schema(service: NovelService, project_id: str, user_id: int) -> NovelProjectSchema:
|
||||
return await service.get_project_schema(project_id, user_id)
|
||||
|
||||
|
||||
def _extract_tail_excerpt(text: Optional[str], limit: int = 500) -> str:
|
||||
"""截取章节结尾文本,默认保留 500 字。"""
|
||||
if not text:
|
||||
return ""
|
||||
stripped = text.strip()
|
||||
if len(stripped) <= limit:
|
||||
return stripped
|
||||
return stripped[-limit:]
|
||||
|
||||
|
||||
@router.post("/novels/{project_id}/chapters/generate", response_model=NovelProjectSchema)
|
||||
async def generate_chapter(
|
||||
project_id: str,
|
||||
request: GenerateChapterRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
novel_service = NovelService(session)
|
||||
prompt_service = PromptService(session)
|
||||
llm_service = LLMService(session)
|
||||
|
||||
project = await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
logger.info("用户 %s 开始为项目 %s 生成第 %s 章", current_user.id, project_id, request.chapter_number)
|
||||
outline = await novel_service.get_outline(project_id, request.chapter_number)
|
||||
if not outline:
|
||||
logger.warning("项目 %s 未找到第 %s 章纲要,生成流程终止", project_id, request.chapter_number)
|
||||
raise HTTPException(status_code=404, detail="蓝图中未找到对应章节纲要")
|
||||
|
||||
chapter = await novel_service.get_or_create_chapter(project_id, request.chapter_number)
|
||||
chapter.real_summary = None
|
||||
chapter.selected_version_id = None
|
||||
chapter.status = "generating"
|
||||
await session.commit()
|
||||
|
||||
outlines_map = {item.chapter_number: item for item in project.outlines}
|
||||
# 收集所有可用的历史章节摘要,便于在 Prompt 中提供前情背景
|
||||
completed_chapters = []
|
||||
latest_prev_number = -1
|
||||
previous_summary_text = ""
|
||||
previous_tail_excerpt = ""
|
||||
for existing in project.chapters:
|
||||
if existing.chapter_number >= request.chapter_number:
|
||||
continue
|
||||
if existing.selected_version is None or not existing.selected_version.content:
|
||||
continue
|
||||
if not existing.real_summary:
|
||||
summary = await llm_service.get_summary(
|
||||
existing.selected_version.content,
|
||||
temperature=0.15,
|
||||
user_id=current_user.id,
|
||||
timeout=180.0,
|
||||
)
|
||||
existing.real_summary = remove_think_tags(summary)
|
||||
await session.commit()
|
||||
completed_chapters.append(
|
||||
{
|
||||
"chapter_number": existing.chapter_number,
|
||||
"title": outlines_map.get(existing.chapter_number).title if outlines_map.get(existing.chapter_number) else f"第{existing.chapter_number}章",
|
||||
"summary": existing.real_summary,
|
||||
}
|
||||
)
|
||||
if existing.chapter_number > latest_prev_number:
|
||||
latest_prev_number = existing.chapter_number
|
||||
previous_summary_text = existing.real_summary or ""
|
||||
previous_tail_excerpt = _extract_tail_excerpt(existing.selected_version.content)
|
||||
|
||||
project_schema = await novel_service._serialize_project(project)
|
||||
blueprint_dict = project_schema.blueprint.model_dump()
|
||||
|
||||
if "relationships" in blueprint_dict and blueprint_dict["relationships"]:
|
||||
for relation in blueprint_dict["relationships"]:
|
||||
if "character_from" in relation:
|
||||
relation["from"] = relation.pop("character_from")
|
||||
if "character_to" in relation:
|
||||
relation["to"] = relation.pop("character_to")
|
||||
|
||||
# 蓝图中禁止携带章节级别的细节信息,避免重复传输大段场景或对话内容
|
||||
banned_blueprint_keys = {
|
||||
"chapter_outline",
|
||||
"chapter_summaries",
|
||||
"chapter_details",
|
||||
"chapter_dialogues",
|
||||
"chapter_events",
|
||||
"conversation_history",
|
||||
"character_timelines",
|
||||
}
|
||||
for key in banned_blueprint_keys:
|
||||
if key in blueprint_dict:
|
||||
blueprint_dict.pop(key, None)
|
||||
|
||||
writer_prompt = await prompt_service.get_prompt("writing")
|
||||
if not writer_prompt:
|
||||
raise HTTPException(status_code=500, detail="缺少写作提示词")
|
||||
|
||||
# 初始化向量检索服务,若未配置则自动降级为纯提示词生成
|
||||
vector_store: Optional[VectorStoreService]
|
||||
if not settings.vector_store_enabled:
|
||||
vector_store = None
|
||||
else:
|
||||
try:
|
||||
vector_store = VectorStoreService()
|
||||
except RuntimeError as exc:
|
||||
logger.warning("向量库初始化失败,RAG 检索被禁用: %s", exc)
|
||||
vector_store = None
|
||||
context_service = ChapterContextService(llm_service=llm_service, vector_store=vector_store)
|
||||
|
||||
outline_title = outline.title or f"第{outline.chapter_number}章"
|
||||
outline_summary = outline.summary or "暂无摘要"
|
||||
query_parts = [outline_title, outline_summary]
|
||||
if request.writing_notes:
|
||||
query_parts.append(request.writing_notes)
|
||||
rag_query = "\n".join(part for part in query_parts if part)
|
||||
rag_context = await context_service.retrieve_for_generation(
|
||||
project_id=project_id,
|
||||
query_text=rag_query or outline.title or outline.summary or "",
|
||||
user_id=current_user.id,
|
||||
)
|
||||
chunk_count = len(rag_context.chunks) if rag_context and rag_context.chunks else 0
|
||||
summary_count = len(rag_context.summaries) if rag_context and rag_context.summaries else 0
|
||||
logger.info(
|
||||
"项目 %s 第 %s 章检索到 %s 个剧情片段和 %s 条摘要",
|
||||
project_id,
|
||||
request.chapter_number,
|
||||
chunk_count,
|
||||
summary_count,
|
||||
)
|
||||
# print("rag_context:",rag_context)
|
||||
# 将蓝图、前情、RAG 检索结果拼装成结构化段落,供模型理解
|
||||
blueprint_text = json.dumps(blueprint_dict, ensure_ascii=False, indent=2)
|
||||
completed_lines = [
|
||||
f"- 第{item['chapter_number']}章 - {item['title']}:{item['summary']}"
|
||||
for item in completed_chapters
|
||||
]
|
||||
previous_summary_text = previous_summary_text or "暂无可用摘要"
|
||||
previous_tail_excerpt = previous_tail_excerpt or "暂无上一章结尾内容"
|
||||
completed_section = "\n".join(completed_lines) if completed_lines else "暂无前情摘要"
|
||||
rag_chunks_text = "\n\n".join(rag_context.chunk_texts()) if rag_context.chunks else "未检索到章节片段"
|
||||
rag_summaries_text = "\n".join(rag_context.summary_lines()) if rag_context.summaries else "未检索到章节摘要"
|
||||
writing_notes = request.writing_notes or "无额外写作指令"
|
||||
|
||||
prompt_sections = [
|
||||
("[世界蓝图](JSON)", blueprint_text),
|
||||
# ("[前情摘要]", completed_section),
|
||||
("[上一章摘要]", previous_summary_text),
|
||||
("[上一章结尾]", previous_tail_excerpt),
|
||||
("[检索到的剧情上下文](Markdown)", rag_chunks_text),
|
||||
("[检索到的章节摘要]", rag_summaries_text),
|
||||
(
|
||||
"[当前章节目标]",
|
||||
f"标题:{outline_title}\n摘要:{outline_summary}\n写作要求:{writing_notes}",
|
||||
),
|
||||
]
|
||||
prompt_input = "\n\n".join(f"{title}\n{content}" for title, content in prompt_sections if content)
|
||||
logger.debug("章节写作提示词:%s\n%s", writer_prompt, prompt_input)
|
||||
async def _generate_single_version(idx: int) -> Dict:
|
||||
try:
|
||||
response = await llm_service.get_llm_response(
|
||||
system_prompt=writer_prompt,
|
||||
conversation_history=[{"role": "user", "content": prompt_input}],
|
||||
temperature=0.9,
|
||||
user_id=current_user.id,
|
||||
timeout=600.0,
|
||||
)
|
||||
cleaned = remove_think_tags(response)
|
||||
normalized = unwrap_markdown_json(cleaned)
|
||||
try:
|
||||
return json.loads(normalized)
|
||||
except json.JSONDecodeError:
|
||||
return {"content": normalized}
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"项目 %s 生成第 %s 章第 %s 个版本时发生异常: %s",
|
||||
project_id,
|
||||
request.chapter_number,
|
||||
idx + 1,
|
||||
exc,
|
||||
)
|
||||
return {"content": f"生成失败: {exc}"}
|
||||
|
||||
version_count = await _resolve_version_count(session)
|
||||
logger.info(
|
||||
"项目 %s 第 %s 章计划生成 %s 个版本",
|
||||
project_id,
|
||||
request.chapter_number,
|
||||
version_count,
|
||||
)
|
||||
raw_versions = []
|
||||
for idx in range(version_count):
|
||||
raw_versions.append(await _generate_single_version(idx))
|
||||
contents: List[str] = []
|
||||
metadata: List[Dict] = []
|
||||
for variant in raw_versions:
|
||||
if isinstance(variant, dict):
|
||||
if "content" in variant and isinstance(variant["content"], str):
|
||||
contents.append(variant["content"])
|
||||
elif "chapter_content" in variant:
|
||||
contents.append(str(variant["chapter_content"]))
|
||||
else:
|
||||
contents.append(json.dumps(variant, ensure_ascii=False))
|
||||
metadata.append(variant)
|
||||
else:
|
||||
contents.append(str(variant))
|
||||
metadata.append({"raw": variant})
|
||||
|
||||
await novel_service.replace_chapter_versions(chapter, contents, metadata)
|
||||
logger.info(
|
||||
"项目 %s 第 %s 章生成完成,已写入 %s 个版本",
|
||||
project_id,
|
||||
request.chapter_number,
|
||||
len(contents),
|
||||
)
|
||||
return await _load_project_schema(novel_service, project_id, current_user.id)
|
||||
|
||||
|
||||
async def _resolve_version_count(session: AsyncSession) -> int:
|
||||
repo = SystemConfigRepository(session)
|
||||
record = await repo.get_by_key("writer.chapter_versions")
|
||||
if record:
|
||||
try:
|
||||
value = int(record.value)
|
||||
if value > 0:
|
||||
return value
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
env_value = os.getenv("WRITER_CHAPTER_VERSION_COUNT")
|
||||
if env_value:
|
||||
try:
|
||||
value = int(env_value)
|
||||
if value > 0:
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
return 3
|
||||
|
||||
|
||||
@router.post("/novels/{project_id}/chapters/select", response_model=NovelProjectSchema)
|
||||
async def select_chapter_version(
|
||||
project_id: str,
|
||||
request: SelectVersionRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
novel_service = NovelService(session)
|
||||
llm_service = LLMService(session)
|
||||
|
||||
project = await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
chapter = next((ch for ch in project.chapters if ch.chapter_number == request.chapter_number), None)
|
||||
if not chapter:
|
||||
logger.warning("项目 %s 未找到第 %s 章,无法选择版本", project_id, request.chapter_number)
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
|
||||
selected = await novel_service.select_chapter_version(chapter, request.version_index)
|
||||
logger.info(
|
||||
"用户 %s 选择了项目 %s 第 %s 章的第 %s 个版本",
|
||||
current_user.id,
|
||||
project_id,
|
||||
request.chapter_number,
|
||||
request.version_index,
|
||||
)
|
||||
if selected and selected.content:
|
||||
summary = await llm_service.get_summary(
|
||||
selected.content,
|
||||
temperature=0.15,
|
||||
user_id=current_user.id,
|
||||
timeout=180.0,
|
||||
)
|
||||
chapter.real_summary = remove_think_tags(summary)
|
||||
await session.commit()
|
||||
|
||||
# 选定版本后同步向量库,确保后续章节可检索到最新内容
|
||||
vector_store: Optional[VectorStoreService]
|
||||
if not settings.vector_store_enabled:
|
||||
vector_store = None
|
||||
else:
|
||||
try:
|
||||
vector_store = VectorStoreService()
|
||||
except RuntimeError as exc:
|
||||
logger.warning("向量库初始化失败,跳过章节向量同步: %s", exc)
|
||||
vector_store = None
|
||||
|
||||
if vector_store:
|
||||
ingestion_service = ChapterIngestionService(llm_service=llm_service, vector_store=vector_store)
|
||||
outline = next((item for item in project.outlines if item.chapter_number == chapter.chapter_number), None)
|
||||
chapter_title = outline.title if outline and outline.title else f"第{chapter.chapter_number}章"
|
||||
await ingestion_service.ingest_chapter(
|
||||
project_id=project_id,
|
||||
chapter_number=chapter.chapter_number,
|
||||
title=chapter_title,
|
||||
content=selected.content,
|
||||
summary=chapter.real_summary,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
logger.info(
|
||||
"项目 %s 第 %s 章已同步至向量库",
|
||||
project_id,
|
||||
chapter.chapter_number,
|
||||
)
|
||||
|
||||
return await _load_project_schema(novel_service, project_id, current_user.id)
|
||||
|
||||
|
||||
@router.post("/novels/{project_id}/chapters/evaluate", response_model=NovelProjectSchema)
|
||||
async def evaluate_chapter(
|
||||
project_id: str,
|
||||
request: EvaluateChapterRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
novel_service = NovelService(session)
|
||||
prompt_service = PromptService(session)
|
||||
llm_service = LLMService(session)
|
||||
|
||||
project = await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
chapter = next((ch for ch in project.chapters if ch.chapter_number == request.chapter_number), None)
|
||||
if not chapter:
|
||||
logger.warning("项目 %s 未找到第 %s 章,无法执行评估", project_id, request.chapter_number)
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
if not chapter.versions:
|
||||
logger.warning("项目 %s 第 %s 章无可评估版本", project_id, request.chapter_number)
|
||||
raise HTTPException(status_code=400, detail="无可评估的章节版本")
|
||||
|
||||
evaluator_prompt = await prompt_service.get_prompt("evaluation")
|
||||
if not evaluator_prompt:
|
||||
logger.error("缺少评估提示词,项目 %s 第 %s 章评估失败", project_id, request.chapter_number)
|
||||
raise HTTPException(status_code=500, detail="缺少评估提示词")
|
||||
|
||||
project_schema = await novel_service._serialize_project(project)
|
||||
blueprint_dict = project_schema.blueprint.model_dump()
|
||||
|
||||
versions_to_evaluate = [
|
||||
{"version_id": idx + 1, "content": version.content}
|
||||
for idx, version in enumerate(sorted(chapter.versions, key=lambda item: item.created_at))
|
||||
]
|
||||
# print("blueprint_dict:",blueprint_dict)
|
||||
evaluator_payload = {
|
||||
"novel_blueprint": blueprint_dict,
|
||||
"content_to_evaluate": {
|
||||
"chapter_number": chapter.chapter_number,
|
||||
"versions": versions_to_evaluate,
|
||||
},
|
||||
}
|
||||
|
||||
evaluation_raw = await llm_service.get_llm_response(
|
||||
system_prompt=evaluator_prompt,
|
||||
conversation_history=[{"role": "user", "content": json.dumps(evaluator_payload, ensure_ascii=False)}],
|
||||
temperature=0.3,
|
||||
user_id=current_user.id,
|
||||
timeout=360.0,
|
||||
)
|
||||
evaluation_clean = remove_think_tags(evaluation_raw)
|
||||
await novel_service.add_chapter_evaluation(chapter, None, evaluation_clean)
|
||||
logger.info("项目 %s 第 %s 章评估完成", project_id, request.chapter_number)
|
||||
|
||||
return await _load_project_schema(novel_service, project_id, current_user.id)
|
||||
|
||||
|
||||
@router.post("/novels/{project_id}/chapters/outline", response_model=NovelProjectSchema)
|
||||
async def generate_chapter_outline(
|
||||
project_id: str,
|
||||
request: GenerateOutlineRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
novel_service = NovelService(session)
|
||||
prompt_service = PromptService(session)
|
||||
llm_service = LLMService(session)
|
||||
|
||||
await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
logger.info(
|
||||
"用户 %s 请求生成项目 %s 的章节大纲,起始章节 %s,数量 %s",
|
||||
current_user.id,
|
||||
project_id,
|
||||
request.start_chapter,
|
||||
request.num_chapters,
|
||||
)
|
||||
outline_prompt = await prompt_service.get_prompt("outline")
|
||||
if not outline_prompt:
|
||||
logger.error("缺少大纲提示词,项目 %s 大纲生成失败", project_id)
|
||||
raise HTTPException(status_code=500, detail="缺少大纲提示词")
|
||||
|
||||
project_schema = await novel_service.get_project_schema(project_id, current_user.id)
|
||||
blueprint_dict = project_schema.blueprint.model_dump()
|
||||
|
||||
payload = {
|
||||
"novel_blueprint": blueprint_dict,
|
||||
"wait_to_generate": {
|
||||
"start_chapter": request.start_chapter,
|
||||
"num_chapters": request.num_chapters,
|
||||
},
|
||||
}
|
||||
|
||||
response = await llm_service.get_llm_response(
|
||||
system_prompt=outline_prompt,
|
||||
conversation_history=[{"role": "user", "content": json.dumps(payload, ensure_ascii=False)}],
|
||||
temperature=0.7,
|
||||
user_id=current_user.id,
|
||||
timeout=360.0,
|
||||
)
|
||||
normalized = unwrap_markdown_json(remove_think_tags(response))
|
||||
try:
|
||||
data = json.loads(normalized)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=500, detail="章节大纲生成失败") from exc
|
||||
|
||||
new_outlines = data.get("chapters", [])
|
||||
for item in new_outlines:
|
||||
stmt = (
|
||||
select(ChapterOutline)
|
||||
.where(
|
||||
ChapterOutline.project_id == project_id,
|
||||
ChapterOutline.chapter_number == item.get("chapter_number"),
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
record = result.scalars().first()
|
||||
if record:
|
||||
record.title = item.get("title", record.title)
|
||||
record.summary = item.get("summary", record.summary)
|
||||
else:
|
||||
session.add(
|
||||
ChapterOutline(
|
||||
project_id=project_id,
|
||||
chapter_number=item.get("chapter_number"),
|
||||
title=item.get("title", ""),
|
||||
summary=item.get("summary"),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info("项目 %s 章节大纲生成完成", project_id)
|
||||
|
||||
return await novel_service.get_project_schema(project_id, current_user.id)
|
||||
|
||||
|
||||
@router.post("/novels/{project_id}/chapters/update-outline", response_model=NovelProjectSchema)
|
||||
async def update_chapter_outline(
|
||||
project_id: str,
|
||||
request: UpdateChapterOutlineRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
novel_service = NovelService(session)
|
||||
await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
logger.info(
|
||||
"用户 %s 更新项目 %s 第 %s 章大纲",
|
||||
current_user.id,
|
||||
project_id,
|
||||
request.chapter_number,
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(ChapterOutline)
|
||||
.where(
|
||||
ChapterOutline.project_id == project_id,
|
||||
ChapterOutline.chapter_number == request.chapter_number,
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
outline = result.scalars().first()
|
||||
if not outline:
|
||||
outline = ChapterOutline(
|
||||
project_id=project_id,
|
||||
chapter_number=request.chapter_number,
|
||||
)
|
||||
session.add(outline)
|
||||
|
||||
outline.title = request.title
|
||||
outline.summary = request.summary
|
||||
await session.commit()
|
||||
logger.info("项目 %s 第 %s 章大纲已更新", project_id, request.chapter_number)
|
||||
|
||||
return await novel_service.get_project_schema(project_id, current_user.id)
|
||||
|
||||
|
||||
@router.post("/novels/{project_id}/chapters/delete", response_model=NovelProjectSchema)
|
||||
async def delete_chapters(
|
||||
project_id: str,
|
||||
request: DeleteChapterRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
if not request.chapter_numbers:
|
||||
logger.warning("项目 %s 未提供要删除的章节号", project_id)
|
||||
raise HTTPException(status_code=400, detail="请提供要删除的章节号")
|
||||
novel_service = NovelService(session)
|
||||
llm_service = LLMService(session)
|
||||
await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
logger.info(
|
||||
"用户 %s 删除项目 %s 的章节 %s",
|
||||
current_user.id,
|
||||
project_id,
|
||||
request.chapter_numbers,
|
||||
)
|
||||
await novel_service.delete_chapters(project_id, request.chapter_numbers)
|
||||
|
||||
# 删除章节时同步清理向量库,避免过时内容被检索
|
||||
vector_store: Optional[VectorStoreService]
|
||||
if not settings.vector_store_enabled:
|
||||
vector_store = None
|
||||
else:
|
||||
try:
|
||||
vector_store = VectorStoreService()
|
||||
except RuntimeError as exc:
|
||||
logger.warning("向量库初始化失败,跳过章节向量删除: %s", exc)
|
||||
vector_store = None
|
||||
|
||||
if vector_store:
|
||||
ingestion_service = ChapterIngestionService(llm_service=llm_service, vector_store=vector_store)
|
||||
await ingestion_service.delete_chapters(project_id, request.chapter_numbers)
|
||||
logger.info(
|
||||
"项目 %s 已从向量库移除章节 %s",
|
||||
project_id,
|
||||
request.chapter_numbers,
|
||||
)
|
||||
|
||||
return await novel_service.get_project_schema(project_id, current_user.id)
|
||||
|
||||
|
||||
@router.post("/novels/{project_id}/chapters/edit", response_model=NovelProjectSchema)
|
||||
async def edit_chapter(
|
||||
project_id: str,
|
||||
request: EditChapterRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> NovelProjectSchema:
|
||||
novel_service = NovelService(session)
|
||||
llm_service = LLMService(session)
|
||||
|
||||
project = await novel_service.ensure_project_owner(project_id, current_user.id)
|
||||
chapter = next((ch for ch in project.chapters if ch.chapter_number == request.chapter_number), None)
|
||||
if not chapter or chapter.selected_version is None:
|
||||
logger.warning("项目 %s 第 %s 章尚未生成或未选择版本,无法编辑", project_id, request.chapter_number)
|
||||
raise HTTPException(status_code=404, detail="章节尚未生成或未选择版本")
|
||||
|
||||
chapter.selected_version.content = request.content
|
||||
chapter.word_count = len(request.content)
|
||||
logger.info("用户 %s 更新了项目 %s 第 %s 章内容", current_user.id, project_id, request.chapter_number)
|
||||
|
||||
if request.content.strip():
|
||||
summary = await llm_service.get_summary(
|
||||
request.content,
|
||||
temperature=0.15,
|
||||
user_id=current_user.id,
|
||||
timeout=180.0,
|
||||
)
|
||||
chapter.real_summary = remove_think_tags(summary)
|
||||
await session.commit()
|
||||
|
||||
vector_store: Optional[VectorStoreService]
|
||||
if not settings.vector_store_enabled:
|
||||
vector_store = None
|
||||
else:
|
||||
try:
|
||||
vector_store = VectorStoreService()
|
||||
except RuntimeError as exc:
|
||||
logger.warning("向量库初始化失败,跳过章节向量更新: %s", exc)
|
||||
vector_store = None
|
||||
|
||||
if vector_store and chapter.selected_version and chapter.selected_version.content:
|
||||
ingestion_service = ChapterIngestionService(llm_service=llm_service, vector_store=vector_store)
|
||||
outline = next((item for item in project.outlines if item.chapter_number == chapter.chapter_number), None)
|
||||
chapter_title = outline.title if outline and outline.title else f"第{chapter.chapter_number}章"
|
||||
await ingestion_service.ingest_chapter(
|
||||
project_id=project_id,
|
||||
chapter_number=chapter.chapter_number,
|
||||
title=chapter_title,
|
||||
content=chapter.selected_version.content,
|
||||
summary=chapter.real_summary,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
logger.info("项目 %s 第 %s 章更新内容已同步至向量库", project_id, chapter.chapter_number)
|
||||
|
||||
return await novel_service.get_project_schema(project_id, current_user.id)
|
||||
Reference in New Issue
Block a user