Compare commits
3 Commits
d47ccd7039
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbdccfa843 | ||
| 66d4bd60c1 | |||
| 6416441539 |
@@ -9,6 +9,7 @@ export default class StoryManager {
|
||||
this.currentStory = null;
|
||||
this.currentNodeKey = 'start';
|
||||
this.categories = [];
|
||||
this.pathHistory = []; // 记录用户走过的路径
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +57,7 @@ export default class StoryManager {
|
||||
try {
|
||||
this.currentStory = await get(`/stories/${storyId}`);
|
||||
this.currentNodeKey = 'start';
|
||||
this.pathHistory = []; // 重置路径历史
|
||||
|
||||
// 记录游玩次数
|
||||
await post(`/stories/${storyId}/play`);
|
||||
@@ -85,6 +87,14 @@ export default class StoryManager {
|
||||
}
|
||||
|
||||
const choice = currentNode.choices[choiceIndex];
|
||||
|
||||
// 记录路径历史
|
||||
this.pathHistory.push({
|
||||
nodeKey: this.currentNodeKey,
|
||||
content: currentNode.content,
|
||||
choice: choice.text
|
||||
});
|
||||
|
||||
this.currentNodeKey = choice.nextNodeKey;
|
||||
|
||||
return this.getCurrentNode();
|
||||
@@ -145,6 +155,39 @@ export default class StoryManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI改写中间章节,生成新的剧情分支
|
||||
* @returns {Object|null} 成功返回新节点,失败返回 null(不改变当前状态)
|
||||
*/
|
||||
async rewriteBranch(storyId, prompt, userId) {
|
||||
try {
|
||||
const currentNode = this.getCurrentNode();
|
||||
const result = await post(`/stories/${storyId}/rewrite-branch`, {
|
||||
userId: userId,
|
||||
currentNodeKey: this.currentNodeKey,
|
||||
pathHistory: this.pathHistory,
|
||||
currentContent: currentNode?.content || '',
|
||||
prompt: prompt
|
||||
}, { timeout: 300000 }); // 5分钟超时,AI生成需要较长时间
|
||||
|
||||
// 检查是否有有效的 nodes
|
||||
if (result && result.nodes) {
|
||||
// AI 成功,将新分支合并到当前故事中
|
||||
Object.assign(this.currentStory.nodes, result.nodes);
|
||||
// 跳转到新分支的入口节点
|
||||
this.currentNodeKey = result.entryNodeKey || 'branch_1';
|
||||
return this.getCurrentNode();
|
||||
}
|
||||
|
||||
// AI 失败,返回 null
|
||||
console.log('AI服务不可用:', result?.error || '未知错误');
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('AI改写分支失败:', error?.errMsg || error?.message || JSON.stringify(error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI续写故事
|
||||
*/
|
||||
|
||||
@@ -71,8 +71,10 @@ export default class ChapterScene extends BaseScene {
|
||||
const cardHeight = 85;
|
||||
const gap = 12;
|
||||
const headerHeight = 80;
|
||||
const contentHeight = this.nodeList.length * (cardHeight + gap) + headerHeight;
|
||||
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight + 20);
|
||||
const bottomPadding = 50; // 底部留出空间
|
||||
const contentHeight = this.nodeList.length * (cardHeight + gap) + headerHeight + bottomPadding;
|
||||
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight);
|
||||
console.log('[ChapterScene] nodeList长度:', this.nodeList.length, 'contentHeight:', contentHeight, 'screenHeight:', this.screenHeight, 'maxScrollY:', this.maxScrollY);
|
||||
}
|
||||
|
||||
update() {}
|
||||
@@ -173,9 +175,17 @@ export default class ChapterScene extends BaseScene {
|
||||
if (this.maxScrollY > 0) {
|
||||
const scrollBarHeight = 50;
|
||||
const scrollBarY = startY + (this.scrollY / this.maxScrollY) * (this.screenHeight - startY - scrollBarHeight - 20);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.2)';
|
||||
this.roundRect(ctx, this.screenWidth - 5, scrollBarY, 3, scrollBarHeight, 1.5);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
this.roundRect(ctx, this.screenWidth - 8, scrollBarY, 5, scrollBarHeight, 2.5);
|
||||
ctx.fill();
|
||||
|
||||
// 如果还没滚动到底部,显示提示
|
||||
if (this.scrollY < this.maxScrollY - 10) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('↑ 上滑查看更多 ↑', this.screenWidth / 2, this.screenHeight - 15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ export default class StoryScene extends BaseScene {
|
||||
// 场景图相关
|
||||
this.sceneImage = null;
|
||||
this.sceneColors = this.generateSceneColors();
|
||||
// AI改写相关
|
||||
this.isAIRewriting = false;
|
||||
}
|
||||
|
||||
// 根据场景生成氛围色
|
||||
@@ -246,16 +248,33 @@ export default class StoryScene extends BaseScene {
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = 'bold 15px sans-serif';
|
||||
ctx.fillStyle = this.sceneColors.accent;
|
||||
const title = this.story.title.length > 10 ? this.story.title.substring(0, 10) + '...' : this.story.title;
|
||||
const title = this.story.title.length > 8 ? this.story.title.substring(0, 8) + '...' : this.story.title;
|
||||
ctx.fillText(title, this.screenWidth / 2, 35);
|
||||
}
|
||||
|
||||
// 进度指示
|
||||
ctx.textAlign = 'right';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
const progress = this.main.storyManager.getProgress ? this.main.storyManager.getProgress() : '';
|
||||
ctx.fillText(progress, this.screenWidth - 15, 35);
|
||||
// AI改写按钮(右上角)
|
||||
const btnX = this.screenWidth - 70;
|
||||
const btnY = 18;
|
||||
const btnW = 55;
|
||||
const btnH = 26;
|
||||
|
||||
// 按钮背景
|
||||
if (this.isAIRewriting) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.2)';
|
||||
} else {
|
||||
const gradient = ctx.createLinearGradient(btnX, btnY, btnX + btnW, btnY);
|
||||
gradient.addColorStop(0, '#a855f7');
|
||||
gradient.addColorStop(1, '#ec4899');
|
||||
ctx.fillStyle = gradient;
|
||||
}
|
||||
this.roundRect(ctx, btnX, btnY, btnW, btnH, 13);
|
||||
ctx.fill();
|
||||
|
||||
// 按钮文字
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(this.isAIRewriting ? '生成中' : 'AI改写', btnX + btnW / 2, btnY + 17);
|
||||
}
|
||||
|
||||
renderDialogBox(ctx) {
|
||||
@@ -300,14 +319,14 @@ export default class StoryScene extends BaseScene {
|
||||
const textY = boxY + 65;
|
||||
const lineHeight = 26;
|
||||
const maxWidth = this.screenWidth - padding * 2;
|
||||
const visibleHeight = boxHeight - 105; // 可见区域高度
|
||||
const visibleHeight = boxHeight - 90; // 增加可见区域高度
|
||||
|
||||
ctx.font = '15px sans-serif';
|
||||
const allLines = this.getWrappedLines(ctx, this.displayText, maxWidth);
|
||||
const totalTextHeight = allLines.length * lineHeight;
|
||||
|
||||
// 计算最大滚动距离
|
||||
this.maxScrollY = Math.max(0, totalTextHeight - visibleHeight);
|
||||
this.maxScrollY = Math.max(0, totalTextHeight - visibleHeight + 30);
|
||||
|
||||
// 自动滚动到最新内容(打字时)
|
||||
if (this.isTyping) {
|
||||
@@ -334,9 +353,17 @@ export default class StoryScene extends BaseScene {
|
||||
if (this.maxScrollY > 0) {
|
||||
const scrollBarHeight = 40;
|
||||
const scrollBarY = boxY + 55 + (this.textScrollY / this.maxScrollY) * (visibleHeight - scrollBarHeight);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.2)';
|
||||
this.roundRect(ctx, this.screenWidth - 6, scrollBarY, 3, scrollBarHeight, 1.5);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||
this.roundRect(ctx, this.screenWidth - 8, scrollBarY, 4, scrollBarHeight, 2);
|
||||
ctx.fill();
|
||||
|
||||
// 如果还没滚动到底部,显示滚动提示
|
||||
if (this.textScrollY < this.maxScrollY - 10 && !this.isTyping) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('↑ 上滑查看更多 ↑', this.screenWidth / 2, this.screenHeight - 25);
|
||||
}
|
||||
}
|
||||
|
||||
// 打字机光标
|
||||
@@ -496,13 +523,15 @@ export default class StoryScene extends BaseScene {
|
||||
const touch = e.touches[0];
|
||||
|
||||
// 滑动对话框内容
|
||||
if (this.isDragging && this.maxScrollY > 0) {
|
||||
if (this.isDragging) {
|
||||
const deltaY = this.lastTouchY - touch.clientY;
|
||||
if (Math.abs(deltaY) > 2) {
|
||||
this.hasMoved = true;
|
||||
}
|
||||
if (this.maxScrollY > 0) {
|
||||
this.textScrollY += deltaY;
|
||||
this.textScrollY = Math.max(0, Math.min(this.textScrollY, this.maxScrollY));
|
||||
}
|
||||
this.lastTouchY = touch.clientY;
|
||||
}
|
||||
}
|
||||
@@ -525,6 +554,18 @@ export default class StoryScene extends BaseScene {
|
||||
return;
|
||||
}
|
||||
|
||||
// AI改写按钮点击
|
||||
const btnX = this.screenWidth - 70;
|
||||
const btnY = 18;
|
||||
const btnW = 55;
|
||||
const btnH = 26;
|
||||
if (y >= btnY && y <= btnY + btnH && x >= btnX && x <= btnX + btnW) {
|
||||
if (!this.isAIRewriting) {
|
||||
this.showAIRewriteInput();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 加速打字
|
||||
if (this.isTyping) {
|
||||
this.displayText = this.targetText;
|
||||
@@ -638,6 +679,71 @@ export default class StoryScene extends BaseScene {
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示AI改写输入框
|
||||
*/
|
||||
showAIRewriteInput() {
|
||||
wx.showModal({
|
||||
title: 'AI改写剧情',
|
||||
editable: true,
|
||||
placeholderText: '输入你的改写指令,如"让主角暴富"',
|
||||
success: (res) => {
|
||||
if (res.confirm && res.content) {
|
||||
this.doAIRewrite(res.content);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行AI改写
|
||||
*/
|
||||
async doAIRewrite(prompt) {
|
||||
if (this.isAIRewriting) return;
|
||||
|
||||
this.isAIRewriting = true;
|
||||
this.main.showLoading('AI正在改写剧情...');
|
||||
|
||||
try {
|
||||
const userId = this.main.userManager.userId || 0;
|
||||
const newNode = await this.main.storyManager.rewriteBranch(
|
||||
this.storyId,
|
||||
prompt,
|
||||
userId
|
||||
);
|
||||
|
||||
this.main.hideLoading();
|
||||
|
||||
if (newNode) {
|
||||
// 成功获取新分支,开始播放
|
||||
this.currentNode = newNode;
|
||||
this.startTypewriter(newNode.content);
|
||||
wx.showToast({
|
||||
title: '改写成功!',
|
||||
icon: 'success',
|
||||
duration: 1500
|
||||
});
|
||||
} else {
|
||||
// AI 失败,继续原故事
|
||||
wx.showToast({
|
||||
title: 'AI暂时不可用,继续原故事',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.main.hideLoading();
|
||||
console.error('AI改写出错:', error);
|
||||
wx.showToast({
|
||||
title: '网络错误,请重试',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
} finally {
|
||||
this.isAIRewriting = false;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.main.userManager.isLoggedIn && this.story) {
|
||||
this.main.userManager.saveProgress(
|
||||
|
||||
@@ -42,5 +42,7 @@
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"editorSetting": {}
|
||||
"editorSetting": {},
|
||||
"libVersion": "3.14.3",
|
||||
"isGameTourist": false
|
||||
}
|
||||
19
server/.dockerignore
Normal file
19
server/.dockerignore
Normal file
@@ -0,0 +1,19 @@
|
||||
# 忽略本地环境配置
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# 忽略缓存
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# 忽略git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# 忽略测试文件
|
||||
test_*.py
|
||||
|
||||
# 忽略IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
19
server/Dockerfile
Normal file
19
server/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装依赖
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
|
||||
|
||||
# 复制代码
|
||||
COPY . .
|
||||
|
||||
# 删除本地环境配置,使用容器环境变量
|
||||
RUN rm -f .env
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 3000
|
||||
|
||||
# 启动命令
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3000"]
|
||||
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,7 @@ import random
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, func, distinct
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
@@ -25,6 +25,20 @@ class RewriteRequest(BaseModel):
|
||||
prompt: str
|
||||
|
||||
|
||||
class PathHistoryItem(BaseModel):
|
||||
nodeKey: str
|
||||
content: str = ""
|
||||
choice: str = ""
|
||||
|
||||
|
||||
class RewriteBranchRequest(BaseModel):
|
||||
userId: int
|
||||
currentNodeKey: str
|
||||
pathHistory: List[PathHistoryItem]
|
||||
currentContent: str
|
||||
prompt: str
|
||||
|
||||
|
||||
# ========== API接口 ==========
|
||||
|
||||
@router.get("")
|
||||
@@ -268,3 +282,59 @@ async def ai_rewrite_ending(story_id: int, request: RewriteRequest, db: AsyncSes
|
||||
"ending_type": "rewrite"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{story_id}/rewrite-branch")
|
||||
async def ai_rewrite_branch(
|
||||
story_id: int,
|
||||
request: RewriteBranchRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""AI改写中间章节,生成新的剧情分支"""
|
||||
if not request.prompt:
|
||||
raise HTTPException(status_code=400, detail="请输入改写指令")
|
||||
|
||||
# 获取故事信息
|
||||
result = await db.execute(select(Story).where(Story.id == story_id))
|
||||
story = result.scalar_one_or_none()
|
||||
|
||||
if not story:
|
||||
raise HTTPException(status_code=404, detail="故事不存在")
|
||||
|
||||
# 将 Pydantic 模型转换为字典列表
|
||||
path_history = [
|
||||
{"nodeKey": item.nodeKey, "content": item.content, "choice": item.choice}
|
||||
for item in request.pathHistory
|
||||
]
|
||||
|
||||
# 调用 AI 服务
|
||||
from app.services.ai import ai_service
|
||||
|
||||
ai_result = await ai_service.rewrite_branch(
|
||||
story_title=story.title,
|
||||
story_category=story.category or "未知",
|
||||
path_history=path_history,
|
||||
current_content=request.currentContent,
|
||||
user_prompt=request.prompt
|
||||
)
|
||||
|
||||
if ai_result and ai_result.get("nodes"):
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"nodes": ai_result["nodes"],
|
||||
"entryNodeKey": ai_result.get("entryNodeKey", "branch_1"),
|
||||
"tokensUsed": ai_result.get("tokens_used", 0)
|
||||
}
|
||||
}
|
||||
|
||||
# AI 服务不可用时,返回空结果(不使用兜底模板)
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"nodes": None,
|
||||
"entryNodeKey": None,
|
||||
"tokensUsed": 0,
|
||||
"error": "AI服务暂时不可用"
|
||||
}
|
||||
}
|
||||
BIN
server/app/services/__pycache__/ai.cpython-310.pyc
Normal file
BIN
server/app/services/__pycache__/ai.cpython-310.pyc
Normal file
Binary file not shown.
@@ -2,8 +2,10 @@
|
||||
AI服务封装模块
|
||||
支持多种AI提供商:DeepSeek, OpenAI, Claude, 通义千问
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List
|
||||
import httpx
|
||||
import json
|
||||
import re
|
||||
|
||||
class AIService:
|
||||
def __init__(self):
|
||||
@@ -13,11 +15,14 @@ class AIService:
|
||||
self.enabled = settings.ai_service_enabled
|
||||
self.provider = settings.ai_provider
|
||||
|
||||
print(f"[AI服务初始化] enabled={self.enabled}, provider={self.provider}")
|
||||
|
||||
# 根据提供商初始化配置
|
||||
if self.provider == "deepseek":
|
||||
self.api_key = settings.deepseek_api_key
|
||||
self.base_url = settings.deepseek_base_url
|
||||
self.model = settings.deepseek_model
|
||||
print(f"[AI服务初始化] DeepSeek配置: api_key={self.api_key[:20] + '...' if self.api_key else 'None'}, base_url={self.base_url}, model={self.model}")
|
||||
elif self.provider == "openai":
|
||||
self.api_key = settings.openai_api_key
|
||||
self.base_url = settings.openai_base_url
|
||||
@@ -86,6 +91,351 @@ class AIService:
|
||||
|
||||
return None
|
||||
|
||||
async def rewrite_branch(
|
||||
self,
|
||||
story_title: str,
|
||||
story_category: str,
|
||||
path_history: List[Dict[str, str]],
|
||||
current_content: str,
|
||||
user_prompt: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
AI改写中间章节,生成新的剧情分支
|
||||
"""
|
||||
print(f"\n[rewrite_branch] ========== 开始调用 ==========")
|
||||
print(f"[rewrite_branch] story_title={story_title}, category={story_category}")
|
||||
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] 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
|
||||
|
||||
# 构建路径历史文本
|
||||
path_text = ""
|
||||
for i, item in enumerate(path_history, 1):
|
||||
path_text += f"第{i}段:{item.get('content', '')}\n"
|
||||
if item.get('choice'):
|
||||
path_text += f" → 用户选择:{item['choice']}\n"
|
||||
|
||||
# 构建系统提示词
|
||||
system_prompt = """你是一个专业的互动故事续写专家。用户正在玩一个互动故事,想要在当前位置改变剧情走向。
|
||||
|
||||
【任务】
|
||||
请从当前节点开始,创作新的剧情分支。新剧情的第一句话必须紧接当前节点最后的情节,仿佛是同一段故事的自然延续,不能有任何跳跃感。
|
||||
|
||||
【写作要求】
|
||||
1. 第一个节点必须紧密衔接当前剧情,像是同一段话的下一句
|
||||
2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合)
|
||||
3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\n\n分隔),包含:场景描写、人物对话、心理活动
|
||||
4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果
|
||||
5. 必须以结局收尾,结局内容要 200-400 字,分 2-3 段,有情感冲击力
|
||||
6. 严格符合用户的改写意图,围绕用户指令展开剧情
|
||||
7. 保持原故事的人物性格、语言风格和世界观
|
||||
8. 对话要自然生动,描写要有画面感
|
||||
|
||||
【重要】内容分段示例:
|
||||
"content": "他的声音在耳边响起,像是一阵温柔的风。\n\n\"我喜欢你。\"他说,目光坚定地看着你。\n\n你的心跳漏了一拍,一时间不知该如何回应。"
|
||||
|
||||
【输出格式】(严格JSON,不要有任何额外文字)
|
||||
{
|
||||
"nodes": {
|
||||
"branch_1": {
|
||||
"content": "新剧情第一段(150-300字)...",
|
||||
"speaker": "旁白",
|
||||
"choices": [
|
||||
{"text": "选项A(5-15字)", "nextNodeKey": "branch_2a"},
|
||||
{"text": "选项B(5-15字)", "nextNodeKey": "branch_2b"}
|
||||
]
|
||||
},
|
||||
"branch_2a": {
|
||||
"content": "...",
|
||||
"speaker": "旁白",
|
||||
"choices": [...]
|
||||
},
|
||||
"branch_ending_good": {
|
||||
"content": "好结局内容(200-400字)...",
|
||||
"speaker": "旁白",
|
||||
"is_ending": true,
|
||||
"ending_name": "结局名称(4-8字)",
|
||||
"ending_type": "good"
|
||||
}
|
||||
},
|
||||
"entryNodeKey": "branch_1"
|
||||
}"""
|
||||
|
||||
# 构建用户提示词
|
||||
user_prompt_text = f"""【原故事信息】
|
||||
故事标题:{story_title}
|
||||
故事分类:{story_category}
|
||||
|
||||
【用户已走过的剧情】
|
||||
{path_text}
|
||||
|
||||
【当前节点】
|
||||
{current_content}
|
||||
|
||||
【用户改写指令】
|
||||
{user_prompt}
|
||||
|
||||
请创作新的剧情分支(输出JSON格式):"""
|
||||
|
||||
print(f"[rewrite_branch] 提示词构建完成,开始调用AI...")
|
||||
print(f"[rewrite_branch] provider={self.provider}")
|
||||
|
||||
try:
|
||||
result = None
|
||||
if self.provider == "openai":
|
||||
print(f"[rewrite_branch] 调用 OpenAI...")
|
||||
result = await self._call_openai_long(system_prompt, user_prompt_text)
|
||||
elif self.provider == "claude":
|
||||
print(f"[rewrite_branch] 调用 Claude...")
|
||||
result = await self._call_claude(f"{system_prompt}\n\n{user_prompt_text}")
|
||||
elif self.provider == "qwen":
|
||||
print(f"[rewrite_branch] 调用 Qwen...")
|
||||
result = await self._call_qwen_long(system_prompt, user_prompt_text)
|
||||
elif self.provider == "deepseek":
|
||||
print(f"[rewrite_branch] 调用 DeepSeek...")
|
||||
result = await self._call_deepseek_long(system_prompt, user_prompt_text)
|
||||
|
||||
print(f"[rewrite_branch] AI调用完成,result存在={result is not None}")
|
||||
|
||||
if result and result.get("content"):
|
||||
print(f"[rewrite_branch] AI返回内容长度={len(result.get('content', ''))}")
|
||||
print(f"[rewrite_branch] AI返回内容前500字: {result.get('content', '')[:500]}")
|
||||
|
||||
# 解析JSON响应
|
||||
parsed = self._parse_branch_json(result["content"])
|
||||
print(f"[rewrite_branch] JSON解析结果: parsed存在={parsed is not None}")
|
||||
|
||||
if parsed:
|
||||
parsed["tokens_used"] = result.get("tokens_used", 0)
|
||||
print(f"[rewrite_branch] 成功! nodes数量={len(parsed.get('nodes', {}))}, tokens={parsed.get('tokens_used')}")
|
||||
return parsed
|
||||
else:
|
||||
print(f"[rewrite_branch] JSON解析失败!")
|
||||
else:
|
||||
print(f"[rewrite_branch] AI返回为空或无content")
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[rewrite_branch] 异常: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def _parse_branch_json(self, content: str) -> Optional[Dict]:
|
||||
"""解析AI返回的分支JSON"""
|
||||
print(f"[_parse_branch_json] 开始解析,内容长度={len(content)}")
|
||||
|
||||
# 移除 markdown 代码块标记
|
||||
clean_content = content.strip()
|
||||
if clean_content.startswith('```'):
|
||||
# 移除开头的 ```json 或 ```
|
||||
clean_content = re.sub(r'^```(?:json)?\s*', '', clean_content)
|
||||
# 移除结尾的 ```
|
||||
clean_content = re.sub(r'\s*```$', '', clean_content)
|
||||
|
||||
try:
|
||||
# 尝试直接解析
|
||||
result = json.loads(clean_content)
|
||||
print(f"[_parse_branch_json] 直接解析成功!")
|
||||
return result
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[_parse_branch_json] 直接解析失败: {e}")
|
||||
|
||||
# 尝试提取JSON块
|
||||
try:
|
||||
# 匹配 { ... } 结构
|
||||
brace_match = re.search(r'\{[\s\S]*\}', clean_content)
|
||||
if brace_match:
|
||||
json_str = brace_match.group(0)
|
||||
print(f"[_parse_branch_json] 找到花括号块,尝试解析...")
|
||||
|
||||
try:
|
||||
result = json.loads(json_str)
|
||||
print(f"[_parse_branch_json] 花括号块解析成功!")
|
||||
return result
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[_parse_branch_json] 花括号块解析失败: {e}")
|
||||
# 打印错误位置附近的内容
|
||||
error_pos = e.pos if hasattr(e, 'pos') else 0
|
||||
start = max(0, error_pos - 100)
|
||||
end = min(len(json_str), error_pos + 100)
|
||||
print(f"[_parse_branch_json] 错误位置附近内容: ...{json_str[start:end]}...")
|
||||
|
||||
# 尝试修复不完整的 JSON
|
||||
print(f"[_parse_branch_json] 尝试修复不完整的JSON...")
|
||||
fixed_json = self._try_fix_incomplete_json(json_str)
|
||||
if fixed_json:
|
||||
print(f"[_parse_branch_json] JSON修复成功!")
|
||||
return fixed_json
|
||||
|
||||
except Exception as e:
|
||||
print(f"[_parse_branch_json] 提取解析异常: {e}")
|
||||
|
||||
print(f"[_parse_branch_json] 所有解析方法都失败了")
|
||||
return None
|
||||
|
||||
def _try_fix_incomplete_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
|
||||
|
||||
nodes_start = nodes_match.end()
|
||||
|
||||
# 找所有完整的 branch 节点
|
||||
branch_pattern = r'"branch_\w+"\s*:\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
|
||||
branches = list(re.finditer(branch_pattern, json_str[nodes_start:]))
|
||||
|
||||
if not branches:
|
||||
return None
|
||||
|
||||
# 取最后一个完整的节点的结束位置
|
||||
last_complete_end = nodes_start + branches[-1].end()
|
||||
|
||||
# 构建修复后的 JSON
|
||||
# 截取到最后一个完整节点,然后补全结构
|
||||
truncated = json_str[:last_complete_end]
|
||||
|
||||
# 补全 JSON 结构
|
||||
fixed = truncated + '\n },\n "entryNodeKey": "branch_1"\n}'
|
||||
|
||||
print(f"[_try_fix_incomplete_json] 修复后的JSON长度: {len(fixed)}")
|
||||
result = json.loads(fixed)
|
||||
|
||||
# 验证结果结构
|
||||
if "nodes" in result and len(result["nodes"]) > 0:
|
||||
print(f"[_try_fix_incomplete_json] 修复后节点数: {len(result['nodes'])}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"[_try_fix_incomplete_json] 修复失败: {e}")
|
||||
|
||||
return None
|
||||
|
||||
async def _call_deepseek_long(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
|
||||
"""调用 DeepSeek API (长文本版本)"""
|
||||
print(f"[_call_deepseek_long] 开始调用...")
|
||||
print(f"[_call_deepseek_long] base_url={self.base_url}")
|
||||
print(f"[_call_deepseek_long] model={self.model}")
|
||||
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
data = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
],
|
||||
"temperature": 0.85,
|
||||
"max_tokens": 6000 # 增加输出长度,确保JSON完整
|
||||
}
|
||||
|
||||
print(f"[_call_deepseek_long] system_prompt长度={len(system_prompt)}")
|
||||
print(f"[_call_deepseek_long] user_prompt长度={len(user_prompt)}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
try:
|
||||
print(f"[_call_deepseek_long] 发送请求到 {url}...")
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
print(f"[_call_deepseek_long] 响应状态码: {response.status_code}")
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
print(f"[_call_deepseek_long] 响应JSON keys: {result.keys()}")
|
||||
|
||||
if "choices" in result and len(result["choices"]) > 0:
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
tokens = result.get("usage", {}).get("total_tokens", 0)
|
||||
print(f"[_call_deepseek_long] 成功! content长度={len(content)}, tokens={tokens}")
|
||||
return {"content": content.strip(), "tokens_used": tokens}
|
||||
else:
|
||||
print(f"[_call_deepseek_long] 响应异常,无choices: {result}")
|
||||
return None
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f"[_call_deepseek_long] HTTP错误: {e.response.status_code} - {e.response.text}")
|
||||
return None
|
||||
except httpx.TimeoutException as e:
|
||||
print(f"[_call_deepseek_long] 请求超时: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[_call_deepseek_long] 其他错误: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
async def _call_openai_long(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
|
||||
"""调用OpenAI API (长文本版本)"""
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
data = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
],
|
||||
"temperature": 0.8,
|
||||
"max_tokens": 2000
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
tokens = result["usage"]["total_tokens"]
|
||||
|
||||
return {"content": content.strip(), "tokens_used": tokens}
|
||||
|
||||
async def _call_qwen_long(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
|
||||
"""调用通义千问API (长文本版本)"""
|
||||
url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
data = {
|
||||
"model": self.model,
|
||||
"input": {
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
"result_format": "message",
|
||||
"temperature": 0.8,
|
||||
"max_tokens": 2000
|
||||
}
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
content = result["output"]["choices"][0]["message"]["content"]
|
||||
tokens = result.get("usage", {}).get("total_tokens", 0)
|
||||
|
||||
return {"content": content.strip(), "tokens_used": tokens}
|
||||
|
||||
async def _call_openai(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
|
||||
"""调用OpenAI API"""
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
|
||||
26
server/container.config.json
Normal file
26
server/container.config.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"containerPort": 3000,
|
||||
"dockerfilePath": "Dockerfile",
|
||||
"buildDir": "",
|
||||
"minNum": 0,
|
||||
"maxNum": 10,
|
||||
"cpu": 0.5,
|
||||
"mem": 1,
|
||||
"policyType": "cpu",
|
||||
"policyThreshold": 60,
|
||||
"envParams": {
|
||||
"SERVER_HOST": "0.0.0.0",
|
||||
"SERVER_PORT": "3000",
|
||||
"DEBUG": "False",
|
||||
"DB_HOST": "10.45.108.178",
|
||||
"DB_PORT": "3306",
|
||||
"DB_USER": "root",
|
||||
"DB_PASSWORD": "!Lgd20020523",
|
||||
"DB_NAME": "stardom_story",
|
||||
"AI_SERVICE_ENABLED": "True",
|
||||
"AI_PROVIDER": "deepseek",
|
||||
"DEEPSEEK_API_KEY": "sk-a685e8a0e97e41e4b3cb70fa6fcc3af1",
|
||||
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
|
||||
"DEEPSEEK_MODEL": "deepseek-chat"
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,4 @@ pydantic==2.5.2
|
||||
pydantic-settings==2.1.0
|
||||
python-dotenv==1.0.0
|
||||
python-multipart==0.0.6
|
||||
httpx==0.27.0
|
||||
|
||||
Reference in New Issue
Block a user