feat: AI改写功能集成角色数据 + UI优化
- 新增story_characters表和seed_characters.sql种子数据(27个角色) - AI改写/续写功能注入角色信息(性别/年龄/外貌/性格) - 首页UI下移避让微信退出按钮 - 个人中心页面布局重构
This commit is contained in:
@@ -26,6 +26,28 @@ class Story(Base):
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
nodes = relationship("StoryNode", back_populates="story", cascade="all, delete-orphan")
|
||||
characters = relationship("StoryCharacter", back_populates="story", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class StoryCharacter(Base):
|
||||
"""故事角色表"""
|
||||
__tablename__ = "story_characters"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
story_id = Column(Integer, ForeignKey("stories.id", ondelete="CASCADE"), nullable=False)
|
||||
name = Column(String(50), nullable=False)
|
||||
role_type = Column(String(20), default="supporting") # protagonist/antagonist/supporting
|
||||
gender = Column(String(10), default="")
|
||||
age_range = Column(String(20), default="")
|
||||
appearance = Column(Text) # 外貌描述
|
||||
personality = Column(Text) # 性格描述
|
||||
background = Column(Text) # 背景故事
|
||||
avatar_prompt = Column(Text) # AI绘图提示词
|
||||
avatar_url = Column(String(500), default="")
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
story = relationship("Story", back_populates="characters")
|
||||
|
||||
|
||||
class StoryNode(Base):
|
||||
|
||||
@@ -10,11 +10,32 @@ from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.story import Story, StoryDraft, DraftStatus
|
||||
from app.models.story import Story, StoryDraft, DraftStatus, StoryCharacter
|
||||
|
||||
router = APIRouter(prefix="/drafts", tags=["草稿箱"])
|
||||
|
||||
|
||||
# ============ 辅助函数 ============
|
||||
|
||||
async def get_story_characters(db: AsyncSession, story_id: int) -> List[dict]:
|
||||
"""获取故事的所有角色并转为字典列表"""
|
||||
result = await db.execute(
|
||||
select(StoryCharacter).where(StoryCharacter.story_id == story_id)
|
||||
)
|
||||
characters = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"name": c.name,
|
||||
"role_type": c.role_type,
|
||||
"gender": c.gender,
|
||||
"age_range": c.age_range,
|
||||
"appearance": c.appearance,
|
||||
"personality": c.personality
|
||||
}
|
||||
for c in characters
|
||||
]
|
||||
|
||||
|
||||
# ============ 请求/响应模型 ============
|
||||
|
||||
class PathHistoryItem(BaseModel):
|
||||
@@ -98,6 +119,9 @@ async def process_ai_rewrite(draft_id: int):
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
# 获取故事角色
|
||||
characters = await get_story_characters(db, story.id)
|
||||
|
||||
# 转换路径历史格式
|
||||
path_history = draft.path_history or []
|
||||
|
||||
@@ -107,7 +131,8 @@ async def process_ai_rewrite(draft_id: int):
|
||||
story_category=story.category or "未知",
|
||||
path_history=path_history,
|
||||
current_content=draft.current_content or "",
|
||||
user_prompt=draft.user_prompt
|
||||
user_prompt=draft.user_prompt,
|
||||
characters=characters
|
||||
)
|
||||
|
||||
if ai_result and ai_result.get("nodes"):
|
||||
@@ -171,6 +196,9 @@ async def process_ai_rewrite_ending(draft_id: int):
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
# 获取故事角色
|
||||
characters = await get_story_characters(db, story.id)
|
||||
|
||||
# 从草稿字段获取结局信息
|
||||
ending_name = draft.current_node_key or "未知结局"
|
||||
ending_content = draft.current_content or ""
|
||||
@@ -181,7 +209,8 @@ async def process_ai_rewrite_ending(draft_id: int):
|
||||
story_category=story.category or "未知",
|
||||
ending_name=ending_name,
|
||||
ending_content=ending_content,
|
||||
user_prompt=draft.user_prompt
|
||||
user_prompt=draft.user_prompt,
|
||||
characters=characters
|
||||
)
|
||||
|
||||
if ai_result and ai_result.get("content"):
|
||||
@@ -266,6 +295,9 @@ async def process_ai_continue_ending(draft_id: int):
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
# 获取故事角色
|
||||
characters = await get_story_characters(db, story.id)
|
||||
|
||||
# 从草稿字段获取结局信息
|
||||
ending_name = draft.current_node_key or "未知结局"
|
||||
ending_content = draft.current_content or ""
|
||||
@@ -276,7 +308,8 @@ async def process_ai_continue_ending(draft_id: int):
|
||||
story_category=story.category or "未知",
|
||||
ending_name=ending_name,
|
||||
ending_content=ending_content,
|
||||
user_prompt=draft.user_prompt
|
||||
user_prompt=draft.user_prompt,
|
||||
characters=characters
|
||||
)
|
||||
|
||||
if ai_result and ai_result.get("nodes"):
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.story import Story, StoryNode, StoryChoice
|
||||
from app.models.story import Story, StoryNode, StoryChoice, StoryCharacter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -214,6 +214,22 @@ async def ai_rewrite_ending(story_id: int, request: RewriteRequest, db: AsyncSes
|
||||
if not story:
|
||||
raise HTTPException(status_code=404, detail="故事不存在")
|
||||
|
||||
# 获取故事角色
|
||||
char_result = await db.execute(
|
||||
select(StoryCharacter).where(StoryCharacter.story_id == story_id)
|
||||
)
|
||||
characters = [
|
||||
{
|
||||
"name": c.name,
|
||||
"role_type": c.role_type,
|
||||
"gender": c.gender,
|
||||
"age_range": c.age_range,
|
||||
"appearance": c.appearance,
|
||||
"personality": c.personality
|
||||
}
|
||||
for c in char_result.scalars().all()
|
||||
]
|
||||
|
||||
# 调用 AI 服务
|
||||
from app.services.ai import ai_service
|
||||
|
||||
@@ -222,7 +238,8 @@ async def ai_rewrite_ending(story_id: int, request: RewriteRequest, db: AsyncSes
|
||||
story_category=story.category or "未知",
|
||||
ending_name=request.ending_name or "未知结局",
|
||||
ending_content=request.ending_content or "",
|
||||
user_prompt=request.prompt
|
||||
user_prompt=request.prompt,
|
||||
characters=characters
|
||||
)
|
||||
|
||||
if ai_result and ai_result.get("content"):
|
||||
@@ -301,6 +318,22 @@ async def ai_rewrite_branch(
|
||||
if not story:
|
||||
raise HTTPException(status_code=404, detail="故事不存在")
|
||||
|
||||
# 获取故事角色
|
||||
char_result = await db.execute(
|
||||
select(StoryCharacter).where(StoryCharacter.story_id == story_id)
|
||||
)
|
||||
characters = [
|
||||
{
|
||||
"name": c.name,
|
||||
"role_type": c.role_type,
|
||||
"gender": c.gender,
|
||||
"age_range": c.age_range,
|
||||
"appearance": c.appearance,
|
||||
"personality": c.personality
|
||||
}
|
||||
for c in char_result.scalars().all()
|
||||
]
|
||||
|
||||
# 将 Pydantic 模型转换为字典列表
|
||||
path_history = [
|
||||
{"nodeKey": item.nodeKey, "content": item.content, "choice": item.choice}
|
||||
@@ -315,7 +348,8 @@ async def ai_rewrite_branch(
|
||||
story_category=story.category or "未知",
|
||||
path_history=path_history,
|
||||
current_content=request.currentContent,
|
||||
user_prompt=request.prompt
|
||||
user_prompt=request.prompt,
|
||||
characters=characters
|
||||
)
|
||||
|
||||
if ai_result and ai_result.get("nodes"):
|
||||
|
||||
@@ -7,6 +7,28 @@ import httpx
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def format_characters_prompt(characters: List[Dict]) -> str:
|
||||
"""格式化角色信息为prompt文本"""
|
||||
if not characters:
|
||||
return ""
|
||||
|
||||
text = "\n【故事角色设定】\n"
|
||||
for char in characters:
|
||||
text += f"- {char.get('name', '未知')}({char.get('role_type', '配角')}):"
|
||||
if char.get('gender'):
|
||||
text += f" {char.get('gender')},"
|
||||
if char.get('age_range'):
|
||||
text += f" {char.get('age_range')},"
|
||||
if char.get('appearance'):
|
||||
text += f" 外貌:{char.get('appearance')[:100]},"
|
||||
if char.get('personality'):
|
||||
text += f" 性格:{char.get('personality')[:100]}"
|
||||
text += "\n"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
class AIService:
|
||||
def __init__(self):
|
||||
from app.config import get_settings
|
||||
@@ -49,7 +71,8 @@ class AIService:
|
||||
story_category: str,
|
||||
ending_name: str,
|
||||
ending_content: str,
|
||||
user_prompt: str
|
||||
user_prompt: str,
|
||||
characters: List[Dict] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
AI改写结局
|
||||
@@ -58,14 +81,19 @@ class AIService:
|
||||
if not self.enabled or not self.api_key:
|
||||
return None
|
||||
|
||||
# 格式化角色信息
|
||||
characters_text = format_characters_prompt(characters) if characters else ""
|
||||
|
||||
# 构建Prompt
|
||||
system_prompt = """你是一个专业的互动故事创作专家。根据用户的改写指令,重新创作故事结局。
|
||||
system_prompt = f"""你是一个专业的互动故事创作专家。根据用户的改写指令,重新创作故事结局。
|
||||
{characters_text}
|
||||
要求:
|
||||
1. 保持原故事的世界观和人物性格
|
||||
2. 结局要有张力和情感冲击
|
||||
3. 结局内容字数控制在200-400字
|
||||
4. 为新结局取一个4-8字的新名字,体现改写后的剧情走向
|
||||
5. 输出格式必须是JSON:{"ending_name": "新结局名称", "content": "结局内容"}"""
|
||||
5. 如果有角色设定,必须保持角色性格和外貌描述的一致性
|
||||
6. 输出格式必须是JSON:{{"ending_name": "新结局名称", "content": "结局内容"}}"""
|
||||
|
||||
user_prompt_text = f"""故事标题:{story_title}
|
||||
故事分类:{story_category}
|
||||
@@ -97,7 +125,8 @@ class AIService:
|
||||
story_category: str,
|
||||
path_history: List[Dict[str, str]],
|
||||
current_content: str,
|
||||
user_prompt: str
|
||||
user_prompt: str,
|
||||
characters: List[Dict] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
AI改写中间章节,生成新的剧情分支
|
||||
@@ -107,12 +136,16 @@ class AIService:
|
||||
print(f"[rewrite_branch] user_prompt={user_prompt}")
|
||||
print(f"[rewrite_branch] path_history长度={len(path_history)}")
|
||||
print(f"[rewrite_branch] current_content长度={len(current_content)}")
|
||||
print(f"[rewrite_branch] characters数量={len(characters) if characters else 0}")
|
||||
print(f"[rewrite_branch] enabled={self.enabled}, api_key存在={bool(self.api_key)}")
|
||||
|
||||
if not self.enabled or not self.api_key:
|
||||
print(f"[rewrite_branch] 服务未启用或API Key为空,返回None")
|
||||
return None
|
||||
|
||||
# 格式化角色信息
|
||||
characters_text = format_characters_prompt(characters) if characters else ""
|
||||
|
||||
# 构建路径历史文本
|
||||
path_text = ""
|
||||
for i, item in enumerate(path_history, 1):
|
||||
@@ -121,19 +154,20 @@ class AIService:
|
||||
path_text += f" → 用户选择:{item['choice']}\n"
|
||||
|
||||
# 构建系统提示词
|
||||
system_prompt = """你是一个专业的互动故事续写专家。用户正在玩一个互动故事,想要在当前位置改变剧情走向。
|
||||
|
||||
system_prompt_header = f"""你是一个专业的互动故事续写专家。用户正在玩一个互动故事,想要在当前位置改变剧情走向。
|
||||
{characters_text}
|
||||
【任务】
|
||||
请从当前节点开始,创作新的剧情分支。新剧情的第一句话必须紧接当前节点最后的情节,仿佛是同一段故事的自然延续,不能有任何跳跃感。
|
||||
|
||||
【写作要求】
|
||||
1. 第一个节点必须紧密衔接当前剧情,像是同一段话的下一句
|
||||
2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合)
|
||||
3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\n\n分隔),包含:场景描写、人物对话、心理活动
|
||||
3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\\n\\n分隔),包含:场景描写、人物对话、心理活动
|
||||
4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果
|
||||
5. 严格符合用户的改写意图,围绕用户指令展开剧情
|
||||
6. 保持原故事的人物性格、语言风格和世界观
|
||||
7. 对话要自然生动,描写要有画面感
|
||||
7. 如果有角色设定,必须保持角色性格和外貌的一致性
|
||||
8. 对话要自然生动,描写要有画面感
|
||||
|
||||
【关于结局 - 极其重要!】
|
||||
★★★ 每一条分支路径的尽头必须是结局节点 ★★★
|
||||
@@ -149,9 +183,10 @@ class AIService:
|
||||
- special 特殊结局:70-90分
|
||||
|
||||
【内容分段示例】
|
||||
"content": "他的声音在耳边响起,像是一阵温柔的风。\n\n\"我喜欢你。\"他说,目光坚定地看着你。\n\n你的心跳漏了一拍,一时间不知该如何回应。"
|
||||
|
||||
【输出格式】(严格JSON,不要有任何额外文字)
|
||||
"content": "他的声音在耳边响起,像是一阵温柔的风。\\n\\n\\"我喜欢你。\\"他说,目光坚定地看着你。\\n\\n你的心跳漏了一拍,一时间不知该如何回应。"
|
||||
"""
|
||||
|
||||
system_prompt_json = """【输出格式】(严格JSON,不要有任何额外文字)
|
||||
{
|
||||
"nodes": {
|
||||
"branch_1": {
|
||||
@@ -179,7 +214,7 @@ class AIService:
|
||||
]
|
||||
},
|
||||
"branch_ending_good": {
|
||||
"content": "好结局内容(200-400字)...\n\n【达成结局:xxx】",
|
||||
"content": "好结局内容(200-400字)...\\n\\n【达成结局:xxx】",
|
||||
"speaker": "旁白",
|
||||
"is_ending": true,
|
||||
"ending_name": "结局名称",
|
||||
@@ -187,7 +222,7 @@ class AIService:
|
||||
"ending_score": 90
|
||||
},
|
||||
"branch_ending_bad": {
|
||||
"content": "坏结局内容...\n\n【达成结局:xxx】",
|
||||
"content": "坏结局内容...\\n\\n【达成结局:xxx】",
|
||||
"speaker": "旁白",
|
||||
"is_ending": true,
|
||||
"ending_name": "结局名称",
|
||||
@@ -195,7 +230,7 @@ class AIService:
|
||||
"ending_score": 40
|
||||
},
|
||||
"branch_ending_neutral": {
|
||||
"content": "中立结局...\n\n【达成结局:xxx】",
|
||||
"content": "中立结局...\\n\\n【达成结局:xxx】",
|
||||
"speaker": "旁白",
|
||||
"is_ending": true,
|
||||
"ending_name": "结局名称",
|
||||
@@ -203,7 +238,7 @@ class AIService:
|
||||
"ending_score": 60
|
||||
},
|
||||
"branch_ending_special": {
|
||||
"content": "特殊结局...\n\n【达成结局:xxx】",
|
||||
"content": "特殊结局...\\n\\n【达成结局:xxx】",
|
||||
"speaker": "旁白",
|
||||
"is_ending": true,
|
||||
"ending_name": "结局名称",
|
||||
@@ -213,6 +248,8 @@ class AIService:
|
||||
},
|
||||
"entryNodeKey": "branch_1"
|
||||
}"""
|
||||
|
||||
system_prompt = system_prompt_header + system_prompt_json
|
||||
|
||||
# 构建用户提示词
|
||||
user_prompt_text = f"""【原故事信息】
|
||||
@@ -280,7 +317,8 @@ class AIService:
|
||||
story_category: str,
|
||||
ending_name: str,
|
||||
ending_content: str,
|
||||
user_prompt: str
|
||||
user_prompt: str,
|
||||
characters: List[Dict] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
AI续写结局,从结局开始续写新的剧情分支
|
||||
@@ -289,15 +327,19 @@ class AIService:
|
||||
print(f"[continue_ending] story_title={story_title}, category={story_category}")
|
||||
print(f"[continue_ending] ending_name={ending_name}")
|
||||
print(f"[continue_ending] user_prompt={user_prompt}")
|
||||
print(f"[continue_ending] characters数量={len(characters) if characters else 0}")
|
||||
print(f"[continue_ending] enabled={self.enabled}, api_key存在={bool(self.api_key)}")
|
||||
|
||||
if not self.enabled or not self.api_key:
|
||||
print(f"[continue_ending] 服务未启用或API Key为空,返回None")
|
||||
return None
|
||||
|
||||
# 构建系统提示词
|
||||
system_prompt = """你是一个专业的互动故事续写专家。用户已经到达故事的某个结局,现在想要从这个结局继续故事发展。
|
||||
# 格式化角色信息
|
||||
characters_text = format_characters_prompt(characters) if characters else ""
|
||||
|
||||
# 构建系统提示词
|
||||
system_prompt_header = f"""你是一个专业的互动故事续写专家。用户已经到达故事的某个结局,现在想要从这个结局继续故事发展。
|
||||
{characters_text}
|
||||
【任务】
|
||||
请从这个结局开始,创作故事的延续。新剧情必须紧接结局内容,仿佛故事并没有在此结束,而是有了新的发展。
|
||||
|
||||
@@ -308,7 +350,8 @@ class AIService:
|
||||
4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果
|
||||
5. 严格符合用户的续写意图,围绕用户指令展开剧情
|
||||
6. 保持原故事的人物性格、语言风格和世界观
|
||||
7. 对话要自然生动,描写要有画面感
|
||||
7. 如果有角色设定,必须保持角色性格和外貌的一致性
|
||||
8. 对话要自然生动,描写要有画面感
|
||||
|
||||
【关于新结局 - 极其重要!】
|
||||
★★★ 每一条分支路径的尽头必须是新结局节点 ★★★
|
||||
@@ -317,8 +360,9 @@ class AIService:
|
||||
- 结局名称 4-8 字,体现剧情走向
|
||||
- 如果有2个选项分支,最终必须有2个不同的结局
|
||||
- 每个结局必须有 "ending_score" 评分(0-100)
|
||||
"""
|
||||
|
||||
【输出格式】(严格JSON,不要有任何额外文字)
|
||||
system_prompt_json = """【输出格式】(严格JSON,不要有任何额外文字)
|
||||
{
|
||||
"nodes": {
|
||||
"continue_1": {
|
||||
@@ -357,6 +401,8 @@ class AIService:
|
||||
"entryNodeKey": "continue_1"
|
||||
}"""
|
||||
|
||||
system_prompt = system_prompt_header + system_prompt_json
|
||||
|
||||
# 构建用户提示词
|
||||
user_prompt_text = f"""【原故事信息】
|
||||
故事标题:{story_title}
|
||||
|
||||
Reference in New Issue
Block a user