From bbdccfa8433c89bd094175c30ed4014a835ddc5f Mon Sep 17 00:00:00 2001 From: wangwuww111 <2816108629@qq.com> Date: Fri, 6 Mar 2026 13:16:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=E4=B8=AD=E9=97=B4=E7=AB=A0=E8=8A=82?= =?UTF-8?q?=E6=94=B9=E5=86=99=E5=8A=9F=E8=83=BD=20+=20=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/js/data/StoryManager.js | 43 +++ client/js/scenes/ChapterScene.js | 18 +- client/js/scenes/StoryScene.js | 134 ++++++- client/project.config.json | 4 +- server/app/__pycache__/config.cpython-310.pyc | Bin 1450 -> 1744 bytes .../routers/__pycache__/story.cpython-310.pyc | Bin 6196 -> 8400 bytes server/app/routers/story.py | 72 +++- .../services/__pycache__/ai.cpython-310.pyc | Bin 0 -> 16175 bytes server/app/services/ai.py | 352 +++++++++++++++++- 9 files changed, 602 insertions(+), 21 deletions(-) create mode 100644 server/app/services/__pycache__/ai.cpython-310.pyc diff --git a/client/js/data/StoryManager.js b/client/js/data/StoryManager.js index 81c8854..4c5d81b 100644 --- a/client/js/data/StoryManager.js +++ b/client/js/data/StoryManager.js @@ -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续写故事 */ diff --git a/client/js/scenes/ChapterScene.js b/client/js/scenes/ChapterScene.js index 1b15d80..796fb39 100644 --- a/client/js/scenes/ChapterScene.js +++ b/client/js/scenes/ChapterScene.js @@ -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); + } } } diff --git a/client/js/scenes/StoryScene.js b/client/js/scenes/StoryScene.js index b82d0b4..a8a9bc6 100644 --- a/client/js/scenes/StoryScene.js +++ b/client/js/scenes/StoryScene.js @@ -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; } - this.textScrollY += deltaY; - this.textScrollY = Math.max(0, Math.min(this.textScrollY, this.maxScrollY)); + 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( diff --git a/client/project.config.json b/client/project.config.json index e0fa724..2e0aabf 100644 --- a/client/project.config.json +++ b/client/project.config.json @@ -42,5 +42,7 @@ "ignore": [], "include": [] }, - "editorSetting": {} + "editorSetting": {}, + "libVersion": "3.14.3", + "isGameTourist": false } \ No newline at end of file diff --git a/server/app/__pycache__/config.cpython-310.pyc b/server/app/__pycache__/config.cpython-310.pyc index 94ca8b8047e73cf6edf65a093cfa9b9467ca77f0..f7196ad431000c58fb994bd1532b2ae08beacabc 100644 GIT binary patch delta 662 zcmZXQyK>V&6o$2H*@(n4jx68eU^_7(F_)N7Far}{1`4_qq}xWLcu$<2IF@Et7?Q?C zQqX2p@B%c@Gs6S$0MvQ}DxQGh?1Bjl(&*c7&r0Xl?#IQiZYgrz1q(0n@PqhnUz9$P z9~WQbtjx-A4GP@bv3P|oK_1;lcbI3qie6(C<8|}~^NlynJ5}S$=q*+=zGCWiyNa7HpO=@50SI_zEzj&=p2)YAan-~lMYbGK0;#O2Y$-xE3@O4 zifAfp8dHi`ig*i4Gh>vnJ41>@iew8ziexiWlt_wHFoUM_CVywLne4$ljZu8^ zeP(+`smbyzJM6@P78M}~2_W|tn{$3%T4uT&kX_6MBsdsY7&#b=Kzu(<(IP1zT_g=6 zWG0KU#v9+_C@9J=NG&R;w!Jxy6&7S`uHJT2hjkmtL$4(#!-T*qDk`fs~&ndy)L)3^qei5D&x7$?MqU InWaS-0mVl{H~;_u diff --git a/server/app/routers/__pycache__/story.cpython-310.pyc b/server/app/routers/__pycache__/story.cpython-310.pyc index 724bd1905673fabd86b25b46dacb5aa1cc4711e3..9d71bd67c812630543a10fd7d3c454d999dc72b7 100644 GIT binary patch literal 8400 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 rNa8bVa{?H~mC(E$$=-{ZvVXZV zTj~nBo0JSHzq7YsPq^JsiiIhH8s`AjMd8%r5jUOVUK2(+3gr0N9iH%FW zn$PE+bLQN0?)ka%*~qo^@lq^i2H^9U|>1bOl{0<;ziCr2~r; z2In``=n!2cl~#b#O1k=?I%)cd)ESUEk1o+!!@A^#I$cYLrQV>_Tlauos*kQ=-Sdj; z>0?rHh$=e*=?#J^uSAGvJYRP0Qqjtbe=48qosdJ`DLz&=wIqRThR4wW%s`azr1(th z6|1ySk`XU!sqeL*aw|d`fTubx=N-88t$a;&A=M4g4v%91m;r+r1WY2=6Cdb(WQF*P z{vXmW{v0|)2E?e5PNzY3N%557^&W8+*+g@mxjAn;Zmv`)Z`ml$8ktRTrr5oIAuKh(zvHl=H&P=N`??tiQ#Z>o75a!U?R0z>=@Zl8L9$K!DSQ#Ue>vJFYUb9FNARsj5!N8AMUZ>Qjo0Dwy9kdVJZPP7 z1m1IkM4qu92Pz%mAymTn__q)?A$%J_8v71XGL+3oJ%O+VAg%k{)yQ}fVJpH2!qT9` z)kte<8%Uf4fKnUM6ObKUQ4IZGrpccce~V1Y=(Y@^vuTBIfPbFKO9vk2{ciEs=*Ft_ z-tfK0;oyfLPhlYr>9cfK;}3f3nK{d4WAc!fY~sZ+0Sn?6!McN6D79`iL7IuF@XTuX9z-8=1Tt>GfebVwKaX!&oZ9>)WBVal+rc>f> zR#xgl8aZZ!Z%23@;RgsmMEDWH4uqWuDTE&*`~)Bqk}=xU)2(uu6)FEIa$`mIOE)_o zzSx|MJ^rvF#q(SeT{`MJDs!||{5sk7Vg?PybNfnmjyYM*oJ!v1FCa54Pa<2e%ASU# zr~6v}Q+J^3CBAB=@hoMoW#@C^^JIbS5xbh-sP=(gS#s=P3HY+8j?yd!WZ zM4JwVDBP_eF~MT)UXpzKz#Ndj$v6$Y|sA_4<*i*_)z%wWiyXvfttz-N9!; zx?Qa8oU5(`YNj^%m1^US$=P>r%+yZZ{bZ`~`w!~pZrr)?+nL&l3ZDE-?d0s~bCBTK zw+=VXT=C7$)P7@Du-@yJ->fK001)oH^>-Y@EF|`{Bp++n2z2{m85J%h$oOUtBs^Z@l@+ z?9}a@6>QFXw~oyR_S&&~w_fu?oE_k{%hJI`&A8Bg*m!>HM|cm?K?HvYJcT5ll|RJg z6xanzPv=&VmI^$JGHha~U|Er0*8$&(qMreXGuna zEZ+}nFSOT!JGE?cB_K8~+g0_9@;J4MuASr95}7CrnPF6`9B(E5G{Pvt9sp0Y184z5?S!+y2YyN`TyqI6p&5qwRsS|+fczJ@46eKY diff --git a/server/app/routers/story.py b/server/app/routers/story.py index fb122fe..3f1686c 100644 --- a/server/app/routers/story.py +++ b/server/app/routers/story.py @@ -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服务暂时不可用" + } + } \ No newline at end of file diff --git a/server/app/services/__pycache__/ai.cpython-310.pyc b/server/app/services/__pycache__/ai.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6401d73ba1a810223ef60874659a5983f24356aa GIT binary patch literal 16175 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 literal 0 HcmV?d00001 diff --git a/server/app/services/ai.py b/server/app/services/ai.py index e14df4f..2fd790c 100644 --- a/server/app/services/ai.py +++ b/server/app/services/ai.py @@ -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"