feat: AI改写功能集成角色数据 + UI优化

- 新增story_characters表和seed_characters.sql种子数据(27个角色)
- AI改写/续写功能注入角色信息(性别/年龄/外貌/性格)
- 首页UI下移避让微信退出按钮
- 个人中心页面布局重构
This commit is contained in:
2026-03-11 18:41:56 +08:00
parent 2470cea7e4
commit 4ac47c8474
8 changed files with 478 additions and 47 deletions

View File

@@ -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):

View File

@@ -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"):

View File

@@ -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"):

View File

@@ -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}