feat: AI改写面板样式优化、封面图片显示、max_tokens调整至8192

This commit is contained in:
wangwuww111
2026-03-16 16:35:59 +08:00
parent 253bc4aed2
commit d111f1a2cf
5 changed files with 1352 additions and 61 deletions

View File

@@ -678,7 +678,7 @@ async def get_drafts(
):
"""获取用户的草稿列表"""
result = await db.execute(
select(StoryDraft, Story.title.label("story_title"))
select(StoryDraft, Story.title.label("story_title"), Story.cover_url)
.join(Story, StoryDraft.story_id == Story.id)
.where(StoryDraft.user_id == userId)
.order_by(StoryDraft.created_at.desc())
@@ -688,6 +688,15 @@ async def get_drafts(
for row in result:
draft = row[0]
story_title = row[1]
story_cover_url = row[2]
# AI创作类型优先使用 ai_nodes 中的封面
cover_url = story_cover_url or ""
if draft.draft_type == "create" and draft.ai_nodes:
ai_cover = draft.ai_nodes.get("coverUrl") if isinstance(draft.ai_nodes, dict) else None
if ai_cover:
cover_url = ai_cover
drafts.append({
"id": draft.id,
"storyId": draft.story_id,
@@ -698,6 +707,7 @@ async def get_drafts(
"isRead": draft.is_read,
"publishedToCenter": draft.published_to_center,
"draftType": draft.draft_type or "rewrite",
"coverUrl": cover_url,
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else "",
"completedAt": draft.completed_at.strftime("%Y-%m-%d %H:%M") if draft.completed_at else None
})
@@ -745,18 +755,25 @@ async def get_published_drafts(
draftType: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""获取已发布到创作中心的草稿列表"""
query = select(StoryDraft, Story.title.label('story_title')).join(
"""获取草稿列表
- rewrite/continue 类型:返回已发布到创作中心的
- create 类型:返回所有已完成的(用户可选择发布)
"""
query = select(StoryDraft, Story.title.label('story_title'), Story.cover_url).join(
Story, StoryDraft.story_id == Story.id
).where(
StoryDraft.user_id == userId,
StoryDraft.published_to_center == True,
StoryDraft.status == DraftStatus.completed
)
# 按类型筛选
if draftType:
query = query.where(StoryDraft.draft_type == draftType)
# create 类型不需要 published_to_center 限制
if draftType != 'create':
query = query.where(StoryDraft.published_to_center == True)
else:
query = query.where(StoryDraft.published_to_center == True)
query = query.order_by(StoryDraft.created_at.desc())
@@ -764,14 +781,25 @@ async def get_published_drafts(
rows = result.all()
drafts = []
for draft, story_title in rows:
for draft, story_title, story_cover_url in rows:
# AI创作类型优先使用 ai_nodes 中的封面
cover_url = story_cover_url or ""
if draft.draft_type == "create" and draft.ai_nodes:
ai_cover = draft.ai_nodes.get("coverUrl") if isinstance(draft.ai_nodes, dict) else None
if ai_cover:
cover_url = ai_cover
drafts.append({
"id": draft.id,
"story_id": draft.story_id, # 添加 story_id 字段
"storyId": draft.story_id,
"storyTitle": story_title or "未知故事",
"title": draft.title or "",
"userPrompt": draft.user_prompt,
"draftType": draft.draft_type or "rewrite",
"status": draft.status.value if draft.status else "completed",
"published_to_center": draft.published_to_center,
"coverUrl": cover_url,
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else ""
})

View File

@@ -2,14 +2,15 @@
故事相关API路由
"""
import random
from fastapi import APIRouter, Depends, Query, HTTPException
import asyncio
from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func, distinct
from typing import Optional, List
from pydantic import BaseModel
from app.database import get_db
from app.models.story import Story, StoryNode, StoryChoice, StoryCharacter
from app.models.story import Story, StoryNode, StoryChoice, StoryCharacter, StoryDraft, DraftStatus
router = APIRouter()
@@ -65,6 +66,15 @@ class GenerateImageRequest(BaseModel):
targetKey: Optional[str] = None # nodeKey 或 characterId
class AICreateStoryRequest(BaseModel):
"""AI创作全新故事请求"""
userId: int
genre: str # 题材
keywords: str # 关键词
protagonist: Optional[str] = None # 主角设定
conflict: Optional[str] = None # 核心冲突
# ========== API接口 ==========
@router.get("")
@@ -837,4 +847,437 @@ async def generate_all_story_images(
"generated": generated,
"failed": failed
}
}
}
# ========== AI创作全新故事 ==========
@router.post("/ai-create")
async def ai_create_story(
request: AICreateStoryRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
"""AI创作全新故事异步处理- 只存储到 story_drafts不创建 Story"""
from app.models.user import User
# 验证用户
user_result = await db.execute(select(User).where(User.id == request.userId))
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 获取或创建虚拟故事(用于满足 story_id 外键约束)
virtual_story = await get_or_create_virtual_story(db)
# 创建草稿记录(完整故事内容将存储在 ai_nodes 中)
draft = StoryDraft(
user_id=request.userId,
story_id=virtual_story.id, # 使用虚拟故事ID
title="AI创作中...",
user_prompt=f"题材:{request.genre}, 关键词:{request.keywords}, 主角:{request.protagonist or ''}, 冲突:{request.conflict or ''}",
draft_type="create",
status=DraftStatus.pending
)
db.add(draft)
await db.commit()
await db.refresh(draft)
# 添加后台任务
background_tasks.add_task(
process_ai_create_story,
draft.id,
request.userId,
request.genre,
request.keywords,
request.protagonist,
request.conflict
)
return {
"code": 0,
"data": {
"draftId": draft.id,
"message": "故事创作已开始,完成后将保存到草稿箱"
}
}
async def get_or_create_virtual_story(db: AsyncSession) -> Story:
"""获取或创建用于AI创作的虚拟故事满足外键约束"""
# 查找已存在的虚拟故事
result = await db.execute(
select(Story).where(Story.title == "[系统] AI创作占位故事")
)
virtual_story = result.scalar_one_or_none()
if not virtual_story:
# 创建虚拟故事
virtual_story = Story(
title="[系统] AI创作占位故事",
description="此故事仅用于AI创作功能的外键占位不可游玩",
category="系统",
status=-99, # 特殊状态,不会出现在任何列表中
cover_url="",
author_id=1 # 系统用户
)
db.add(virtual_story)
await db.commit()
await db.refresh(virtual_story)
return virtual_story
@router.get("/ai-create/{draft_id}/status")
async def get_ai_create_status(
draft_id: int,
db: AsyncSession = Depends(get_db)
):
"""获取AI创作状态通过 draft_id 查询)"""
draft_result = await db.execute(
select(StoryDraft).where(StoryDraft.id == draft_id)
)
draft = draft_result.scalar_one_or_none()
if not draft:
raise HTTPException(status_code=404, detail="草稿不存在")
is_completed = draft.status == DraftStatus.completed
is_failed = draft.status == DraftStatus.failed
return {
"code": 0,
"data": {
"draftId": draft.id,
"status": -1 if is_failed else (1 if is_completed else 0),
"title": draft.title,
"isCompleted": is_completed,
"isFailed": is_failed,
"errorMessage": draft.error_message if is_failed else None
}
}
@router.post("/ai-create/{draft_id}/publish")
async def publish_ai_created_story(
draft_id: int,
db: AsyncSession = Depends(get_db)
):
"""发布AI创作的草稿到'我的作品'"""
draft_result = await db.execute(
select(StoryDraft).where(StoryDraft.id == draft_id)
)
draft = draft_result.scalar_one_or_none()
if not draft:
raise HTTPException(status_code=404, detail="草稿不存在")
if draft.status != DraftStatus.completed:
raise HTTPException(status_code=400, detail="草稿尚未完成或已失败")
if draft.published_to_center:
raise HTTPException(status_code=400, detail="草稿已发布")
# 标记为已发布
draft.published_to_center = True
await db.commit()
return {
"code": 0,
"data": {
"draftId": draft.id,
"title": draft.title,
"message": "发布成功!可在'我的作品'中查看"
}
}
async def generate_draft_cover(
story_id: int,
draft_id: int,
title: str,
description: str,
category: str
) -> str:
"""
为AI创作的草稿生成封面图片
返回封面图片的URL路径
"""
from app.services.image_gen import ImageGenService
from app.config import get_settings
import os
import base64
settings = get_settings()
service = ImageGenService()
# 检测是否是云端环境
is_cloud = os.environ.get('TCB_ENV') or os.environ.get('CBR_ENV_ID')
# 生成封面图
cover_prompt = f"Book cover for {category} story titled '{title}'. {description[:100] if description else ''}. Vertical cover image, anime style, vibrant colors, eye-catching design, high quality illustration."
print(f"[generate_draft_cover] 生成封面图: {title}")
result = await service.generate_image(cover_prompt, "cover", "anime")
if not result or not result.get("success"):
print(f"[generate_draft_cover] 封面图生成失败: {result.get('error') if result else 'Unknown'}")
return None
image_bytes = base64.b64decode(result["image_data"])
cover_path = f"uploads/stories/{story_id}/drafts/{draft_id}/cover.jpg"
if is_cloud:
# 云端环境:上传到云存储
try:
from app.routers.drafts import upload_to_cloud_storage
await upload_to_cloud_storage(image_bytes, cover_path)
print(f"[generate_draft_cover] ✓ 云端封面图上传成功")
return f"/{cover_path}"
except Exception as e:
print(f"[generate_draft_cover] 云端上传失败: {e}")
return None
else:
# 本地环境:保存到文件系统
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..', settings.upload_path))
full_path = os.path.join(base_dir, "stories", str(story_id), "drafts", str(draft_id), "cover.jpg")
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "wb") as f:
f.write(image_bytes)
print(f"[generate_draft_cover] ✓ 本地封面图保存成功")
return f"/{cover_path}"
async def process_ai_create_story(
draft_id: int,
user_id: int,
genre: str,
keywords: str,
protagonist: str = None,
conflict: str = None
):
"""后台异步处理AI创作故事 - 将完整故事内容存入 ai_nodes"""
from app.database import async_session_factory
from app.services.ai import ai_service
print(f"\n[process_ai_create_story] ========== 开始创作 ==========")
print(f"[process_ai_create_story] draft_id={draft_id}, user_id={user_id}")
print(f"[process_ai_create_story] genre={genre}, keywords={keywords}")
async with async_session_factory() as db:
try:
# 获取草稿记录
draft_result = await db.execute(select(StoryDraft).where(StoryDraft.id == draft_id))
draft = draft_result.scalar_one_or_none()
if not draft:
print(f"[process_ai_create_story] 草稿不存在")
return
# 调用AI服务创作故事
print(f"[process_ai_create_story] 开始调用AI服务...")
ai_result = await ai_service.create_story(
genre=genre,
keywords=keywords,
protagonist=protagonist,
conflict=conflict,
user_id=user_id
)
if not ai_result:
print(f"[process_ai_create_story] AI创作失败")
draft.status = DraftStatus.failed
draft.error_message = "AI创作失败"
await db.commit()
return
print(f"[process_ai_create_story] AI创作成功开始生成配图...")
# 获取故事节点并生成背景图(失败不影响创作结果)
story_nodes = ai_result.get("nodes", {})
story_category = ai_result.get("category", genre)
story_title = ai_result.get("title", "未命名故事")
story_description = ai_result.get("description", "")
# 生成封面图
try:
cover_url = await generate_draft_cover(
story_id=draft.story_id,
draft_id=draft_id,
title=story_title,
description=story_description,
category=story_category
)
if cover_url:
ai_result["coverUrl"] = cover_url
print(f"[process_ai_create_story] 封面图生成成功: {cover_url}")
except Exception as cover_e:
print(f"[process_ai_create_story] 封面图生成失败: {cover_e}")
# 生成节点背景图
if story_nodes:
try:
from app.routers.drafts import generate_draft_images
await generate_draft_images(
story_id=draft.story_id,
draft_id=draft_id,
ai_nodes=story_nodes,
story_category=story_category
)
print(f"[process_ai_create_story] 配图生成完成")
except Exception as img_e:
print(f"[process_ai_create_story] 配图生成失败(不影响创作结果): {img_e}")
print(f"[process_ai_create_story] 保存到草稿...")
# 将完整故事内容存入 ai_nodes包含已生成的 background_url
draft.title = ai_result.get("title", "未命名故事")
draft.ai_nodes = ai_result # 存储完整的AI结果包含 title, description, characters, nodes, startNodeKey
draft.entry_node_key = ai_result.get("startNodeKey", "start")
draft.status = DraftStatus.completed
await db.commit()
print(f"[process_ai_create_story] ========== 创作完成(已保存到草稿箱) ==========")
print(f"[process_ai_create_story] 故事标题: {draft.title}")
print(f"[process_ai_create_story] 节点数量: {len(story_nodes)}")
except Exception as e:
print(f"[process_ai_create_story] 异常: {e}")
import traceback
traceback.print_exc()
try:
draft.status = DraftStatus.failed
draft.error_message = str(e)[:200]
await db.commit()
except:
pass
async def generate_story_images(story_id: int, ai_result: dict, genre: str):
"""为AI创作的故事生成图片"""
from app.database import async_session_factory
from app.services.image_gen import ImageGenService
from app.config import get_settings
import os
import base64
print(f"\n[generate_story_images] 开始为故事 {story_id} 生成图片")
settings = get_settings()
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..', settings.upload_path))
story_dir = os.path.join(base_dir, "stories", str(story_id))
service = ImageGenService()
async with async_session_factory() as db:
try:
# 1. 生成封面图
print(f"[generate_story_images] 生成封面图...")
title = ai_result.get("title", "")
description = ai_result.get("description", "")
cover_prompt = f"Book cover for {genre} story titled '{title}'. {description[:100]}. Vertical cover image, anime style, vibrant colors, eye-catching design."
cover_result = await service.generate_image(cover_prompt, "cover", "anime")
if cover_result and cover_result.get("success"):
cover_dir = os.path.join(story_dir, "cover")
os.makedirs(cover_dir, exist_ok=True)
cover_path = os.path.join(cover_dir, "cover.jpg")
with open(cover_path, "wb") as f:
f.write(base64.b64decode(cover_result["image_data"]))
# 更新数据库
await db.execute(
update(Story)
.where(Story.id == story_id)
.values(cover_url=f"/uploads/stories/{story_id}/cover/cover.jpg")
)
print(f" ✓ 封面图生成成功")
else:
print(f" ✗ 封面图生成失败")
await asyncio.sleep(1)
# 2. 生成角色头像
print(f"[generate_story_images] 生成角色头像...")
characters = ai_result.get("characters", [])
char_result = await db.execute(
select(StoryCharacter).where(StoryCharacter.story_id == story_id)
)
db_characters = char_result.scalars().all()
char_dir = os.path.join(story_dir, "characters")
os.makedirs(char_dir, exist_ok=True)
for db_char in db_characters:
# 找到对应的AI生成数据
char_data = next((c for c in characters if c.get("name") == db_char.name), None)
appearance = db_char.appearance or ""
avatar_prompt = f"Character portrait: {db_char.name}, {db_char.gender}, {appearance}. Anime style avatar, head and shoulders, clear face, high quality."
avatar_result = await service.generate_image(avatar_prompt, "avatar", "anime")
if avatar_result and avatar_result.get("success"):
avatar_path = os.path.join(char_dir, f"{db_char.id}.jpg")
with open(avatar_path, "wb") as f:
f.write(base64.b64decode(avatar_result["image_data"]))
await db.execute(
update(StoryCharacter)
.where(StoryCharacter.id == db_char.id)
.values(avatar_url=f"/uploads/stories/{story_id}/characters/{db_char.id}.jpg")
)
print(f" ✓ 角色 {db_char.name} 头像生成成功")
else:
print(f" ✗ 角色 {db_char.name} 头像生成失败")
await asyncio.sleep(1)
# 3. 生成节点背景图
print(f"[generate_story_images] 生成节点背景图...")
nodes_data = ai_result.get("nodes", {})
nodes_dir = os.path.join(story_dir, "nodes")
for node_key, node_data in nodes_data.items():
content = node_data.get("content", "")[:150]
bg_prompt = f"Background scene for {genre} story. Scene: {content}. Wide shot, atmospheric, no characters, anime style, vivid colors."
bg_result = await service.generate_image(bg_prompt, "background", "anime")
if bg_result and bg_result.get("success"):
node_dir = os.path.join(nodes_dir, node_key)
os.makedirs(node_dir, exist_ok=True)
bg_path = os.path.join(node_dir, "background.jpg")
with open(bg_path, "wb") as f:
f.write(base64.b64decode(bg_result["image_data"]))
await db.execute(
update(StoryNode)
.where(StoryNode.story_id == story_id)
.where(StoryNode.node_key == node_key)
.values(background_image=f"/uploads/stories/{story_id}/nodes/{node_key}/background.jpg")
)
print(f" ✓ 节点 {node_key} 背景图生成成功")
else:
print(f" ✗ 节点 {node_key} 背景图生成失败")
await asyncio.sleep(1)
await db.commit()
print(f"[generate_story_images] 图片生成完成")
except Exception as e:
print(f"[generate_story_images] 异常: {e}")
import traceback
traceback.print_exc()

View File

@@ -453,6 +453,356 @@ class AIService:
traceback.print_exc()
return None
async def create_story(
self,
genre: str,
keywords: str,
protagonist: str = None,
conflict: str = None,
user_id: int = None
) -> Optional[Dict[str, Any]]:
"""
AI创作全新故事
:return: 包含完整故事结构的字典,或 None
"""
print(f"\n[create_story] ========== 开始创作 ==========")
print(f"[create_story] genre={genre}, keywords={keywords}")
print(f"[create_story] protagonist={protagonist}, conflict={conflict}")
print(f"[create_story] enabled={self.enabled}, api_key存在={bool(self.api_key)}")
if not self.enabled or not self.api_key:
print(f"[create_story] 服务未启用或API Key为空返回None")
return None
# 构建系统提示词
system_prompt = """你是一个专业的互动故事创作专家。请根据用户提供的题材和关键词,创作一个完整的互动故事。
【故事结构要求】
1. 故事要有吸引人的标题10字以内和简介50-100字
2. 创建2-3个主要角色每个角色需要详细设定
3. 故事包含6-8个节点形成多分支结构
4. 必须有2-4个不同类型的结局good/bad/neutral/special
5. 每个非结局节点有2个选项选项要有明显的剧情差异
【角色设定要求】
每个角色需要:
- name: 角色名2-4字
- role_type: 角色类型protagonist/antagonist/supporting
- gender: 性别male/female
- age_range: 年龄段youth/adult/middle_aged/elderly
- appearance: 外貌描述50-100字包含发型、眼睛、身材、穿着等
- personality: 性格特点30-50字
【节点内容要求】
- 每个节点150-300字分2-3段\\n\\n分隔
- 包含场景描写、人物对话、心理活动
- 对话要自然生动,描写要有画面感
【结局要求】
- 结局内容200-400字有情感冲击力
- 结局名称4-8字体现剧情走向
- 结局需要评分ending_scoregood 80-100, bad 20-50, neutral 50-70, special 70-90
【输出格式】严格JSON不要有任何额外文字
{
"title": "故事标题",
"description": "故事简介50-100字",
"category": "题材分类",
"characters": [
{
"name": "角色名",
"role_type": "protagonist",
"gender": "male",
"age_range": "youth",
"appearance": "外貌描述...",
"personality": "性格特点..."
}
],
"nodes": {
"start": {
"content": "开篇内容...",
"speaker": "旁白",
"choices": [
{"text": "选项A", "nextNodeKey": "node_1a"},
{"text": "选项B", "nextNodeKey": "node_1b"}
]
},
"node_1a": {
"content": "...",
"speaker": "旁白",
"choices": [...]
},
"ending_good": {
"content": "好结局内容...\\n\\n【达成结局xxx】",
"speaker": "旁白",
"is_ending": true,
"ending_name": "结局名称",
"ending_type": "good",
"ending_score": 90
}
},
"startNodeKey": "start"
}"""
# 构建用户提示词
protagonist_text = f"\n主角设定:{protagonist}" if protagonist else ""
conflict_text = f"\n核心冲突:{conflict}" if conflict else ""
user_prompt_text = f"""请创作一个互动故事:
【题材】{genre}
【关键词】{keywords}{protagonist_text}{conflict_text}
请创作完整的故事输出JSON格式"""
print(f"[create_story] 提示词构建完成开始调用AI...")
try:
result = None
if self.provider == "openai":
result = await self._call_openai_long(system_prompt, user_prompt_text)
elif self.provider == "claude":
result = await self._call_claude(f"{system_prompt}\n\n{user_prompt_text}")
elif self.provider == "qwen":
result = await self._call_qwen_long(system_prompt, user_prompt_text)
elif self.provider == "deepseek":
result = await self._call_deepseek_long(system_prompt, user_prompt_text)
print(f"[create_story] AI调用完成result存在={result is not None}")
if result and result.get("content"):
print(f"[create_story] AI返回内容长度={len(result.get('content', ''))}")
# 解析JSON响应
parsed = self._parse_story_json(result["content"])
print(f"[create_story] JSON解析结果: parsed存在={parsed is not None}")
if parsed:
parsed["tokens_used"] = result.get("tokens_used", 0)
print(f"[create_story] 成功! title={parsed.get('title')}, nodes数量={len(parsed.get('nodes', {}))}")
return parsed
else:
print(f"[create_story] JSON解析失败!")
return None
except Exception as e:
print(f"[create_story] 异常: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
return None
def _parse_story_json(self, content: str) -> Optional[Dict]:
"""解析AI返回的故事JSON"""
print(f"[_parse_story_json] 开始解析,内容长度={len(content)}")
# 移除 markdown 代码块标记
clean_content = content.strip()
if clean_content.startswith('```'):
clean_content = re.sub(r'^```(?:json)?\s*', '', clean_content)
clean_content = re.sub(r'\s*```$', '', clean_content)
result = None
# 方法1: 直接解析
try:
result = json.loads(clean_content)
if all(k in result for k in ['title', 'nodes', 'startNodeKey']):
print(f"[_parse_story_json] 直接解析成功!")
except json.JSONDecodeError as e:
print(f"[_parse_story_json] 直接解析失败: {e}")
result = None
# 方法2: 提取JSON块
if not result:
try:
brace_match = re.search(r'\{[\s\S]*\}', clean_content)
if brace_match:
json_str = brace_match.group(0)
result = json.loads(json_str)
if all(k in result for k in ['title', 'nodes', 'startNodeKey']):
print(f"[_parse_story_json] 花括号块解析成功!")
else:
result = None
except json.JSONDecodeError as e:
print(f"[_parse_story_json] 花括号块解析失败: {e}")
# 尝试修复截断的JSON
try:
result = self._try_fix_story_json(json_str)
if result:
print(f"[_parse_story_json] JSON修复成功!")
except:
pass
except Exception as e:
print(f"[_parse_story_json] 提取解析失败: {e}")
if not result:
print(f"[_parse_story_json] 所有解析方法都失败了")
return None
# 验证并修复故事结构
result = self._validate_and_fix_story(result)
return result
def _validate_and_fix_story(self, story: Dict) -> Dict:
"""验证并修复故事结构,确保每个分支都有结局"""
nodes = story.get('nodes', {})
if not nodes:
return story
print(f"[_validate_and_fix_story] 开始验证,节点数={len(nodes)}")
# 1. 找出所有结局节点
ending_nodes = [k for k, v in nodes.items() if v.get('is_ending')]
print(f"[_validate_and_fix_story] 已有结局节点: {ending_nodes}")
# 2. 找出所有被引用的节点(作为 nextNodeKey
referenced_keys = set()
for node_key, node_data in nodes.items():
choices = node_data.get('choices', [])
if isinstance(choices, list):
for choice in choices:
if isinstance(choice, dict) and 'nextNodeKey' in choice:
referenced_keys.add(choice['nextNodeKey'])
# 3. 找出"叶子节点":没有 choices 或 choices 为空,且不是结局
leaf_nodes = []
broken_refs = [] # 引用了不存在节点的选项
for node_key, node_data in nodes.items():
choices = node_data.get('choices', [])
is_ending = node_data.get('is_ending', False)
# 检查 choices 中引用的节点是否存在
if isinstance(choices, list):
for choice in choices:
if isinstance(choice, dict):
next_key = choice.get('nextNodeKey')
if next_key and next_key not in nodes:
broken_refs.append((node_key, next_key))
# 没有有效选项且不是结局的节点
if not is_ending and (not choices or len(choices) == 0):
leaf_nodes.append(node_key)
print(f"[_validate_and_fix_story] 叶子节点(无选项非结局): {leaf_nodes}")
print(f"[_validate_and_fix_story] 断裂引用: {broken_refs}")
# 4. 修复:将叶子节点标记为结局
for node_key in leaf_nodes:
node = nodes[node_key]
print(f"[_validate_and_fix_story] 修复节点 {node_key} -> 标记为结局")
node['is_ending'] = True
if not node.get('ending_name'):
node['ending_name'] = '命运的转折'
if not node.get('ending_type'):
node['ending_type'] = 'neutral'
if not node.get('ending_score'):
node['ending_score'] = 60
# 5. 修复:处理断裂引用(选项指向不存在的节点)
for node_key, missing_key in broken_refs:
node = nodes[node_key]
choices = node.get('choices', [])
# 移除指向不存在节点的选项
valid_choices = [c for c in choices if c.get('nextNodeKey') in nodes]
if len(valid_choices) == 0:
# 没有有效选项了,标记为结局
print(f"[_validate_and_fix_story] 节点 {node_key} 所有选项失效 -> 标记为结局")
node['is_ending'] = True
node['choices'] = []
if not node.get('ending_name'):
node['ending_name'] = '未知结局'
if not node.get('ending_type'):
node['ending_type'] = 'neutral'
if not node.get('ending_score'):
node['ending_score'] = 50
else:
node['choices'] = valid_choices
# 6. 最终检查:确保至少有一个结局
ending_count = sum(1 for v in nodes.values() if v.get('is_ending'))
print(f"[_validate_and_fix_story] 修复后结局数: {ending_count}")
if ending_count == 0:
# 如果还是没有结局,找最后一个节点标记为结局
last_key = list(nodes.keys())[-1]
print(f"[_validate_and_fix_story] 强制将最后节点 {last_key} 标记为结局")
nodes[last_key]['is_ending'] = True
nodes[last_key]['ending_name'] = '故事的终点'
nodes[last_key]['ending_type'] = 'neutral'
nodes[last_key]['ending_score'] = 60
nodes[last_key]['choices'] = []
return story
def _try_fix_story_json(self, json_str: str) -> Optional[Dict]:
"""尝试修复不完整的故事JSON"""
try:
# 尝试找到最后一个完整的节点
# 查找 "nodes": { 的位置
nodes_match = re.search(r'"nodes"\s*:\s*\{', json_str)
if not nodes_match:
return None
# 找所有看起来完整的节点(有 "content" 字段的)
node_pattern = r'"(\w+)"\s*:\s*\{[^{}]*"content"[^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
nodes = list(re.finditer(node_pattern, json_str[nodes_match.end():]))
if len(nodes) < 2:
return None
# 取到最后一个完整节点的位置
last_node_end = nodes_match.end() + nodes[-1].end()
# 尝试提取基本信息
title_match = re.search(r'"title"\s*:\s*"([^"]+)"', json_str)
desc_match = re.search(r'"description"\s*:\s*"([^"]+)"', json_str)
category_match = re.search(r'"category"\s*:\s*"([^"]+)"', json_str)
start_match = re.search(r'"startNodeKey"\s*:\s*"([^"]+)"', json_str)
title = title_match.group(1) if title_match else "AI创作故事"
description = desc_match.group(1) if desc_match else ""
category = category_match.group(1) if category_match else "都市言情"
startNodeKey = start_match.group(1) if start_match else "start"
# 提取角色
characters = []
char_match = re.search(r'"characters"\s*:\s*\[([\s\S]*?)\]', json_str)
if char_match:
try:
characters = json.loads('[' + char_match.group(1) + ']')
except:
pass
# 提取节点
nodes_content = json_str[nodes_match.start():last_node_end] + '}'
try:
nodes_obj = json.loads('{' + nodes_content + '}')
nodes_dict = nodes_obj.get('nodes', {})
except:
return None
if len(nodes_dict) < 2:
return None
result = {
"title": title,
"description": description,
"category": category,
"characters": characters,
"nodes": nodes_dict,
"startNodeKey": startNodeKey
}
print(f"[_try_fix_story_json] 修复成功! 节点数={len(nodes_dict)}")
return result
except Exception as e:
print(f"[_try_fix_story_json] 修复失败: {e}")
return None
def _parse_branch_json(self, content: str) -> Optional[Dict]:
"""解析AI返回的分支JSON"""
print(f"[_parse_branch_json] 开始解析,内容长度={len(content)}")
@@ -567,7 +917,7 @@ class AIService:
{"role": "user", "content": user_prompt}
],
"temperature": 0.85,
"max_tokens": 6000 # 增加输出长度确保JSON完整
"max_tokens": 8192 # DeepSeek 最大输出限制
}
print(f"[_call_deepseek_long] system_prompt长度={len(system_prompt)}")