diff --git a/client/js/scenes/ProfileScene.js b/client/js/scenes/ProfileScene.js index 764f824..dd0262a 100644 --- a/client/js/scenes/ProfileScene.js +++ b/client/js/scenes/ProfileScene.js @@ -2,6 +2,7 @@ * 个人中心场景 - 支持创作者功能 */ import BaseScene from './BaseScene'; +import { getStaticUrl } from '../utils/http'; export default class ProfileScene extends BaseScene { constructor(main, params) { @@ -40,6 +41,9 @@ export default class ProfileScene extends BaseScene { this.lastTouchY = 0; this.scrollVelocity = 0; this.hasMoved = false; + + // 封面图片缓存 + this.coverImages = {}; // { url: Image对象 } } async init() { @@ -69,14 +73,45 @@ export default class ProfileScene extends BaseScene { } } + // 加载封面图片 + loadCoverImage(url) { + if (!url || this.coverImages[url] !== undefined) return; + + // 标记为加载中 + this.coverImages[url] = null; + + const img = wx.createImage(); + img.onload = () => { + this.coverImages[url] = img; + }; + img.onerror = () => { + this.coverImages[url] = false; // 加载失败 + }; + + // 使用 getStaticUrl 处理 URL(与首页一致) + img.src = getStaticUrl(url); + } + + // 预加载当前列表的封面图片 + preloadCoverImages() { + const list = this.getCurrentList(); + list.forEach(item => { + const coverUrl = item.coverUrl || item.cover_url; + if (coverUrl) { + this.loadCoverImage(coverUrl); + } + }); + } + async loadData() { if (this.main.userManager.isLoggedIn) { try { const userId = this.main.userManager.userId; - // 加载已发布到创作中心的作品(改写+续写) + // 加载已发布到创作中心的作品(改写+续写+创作) const publishedRewrites = await this.main.userManager.getPublishedDrafts('rewrite') || []; const publishedContinues = await this.main.userManager.getPublishedDrafts('continue') || []; - this.myWorks = [...publishedRewrites, ...publishedContinues]; + const publishedCreates = await this.main.userManager.getPublishedDrafts('create') || []; + this.myWorks = [...publishedRewrites, ...publishedContinues, ...publishedCreates]; // 加载 AI 改写草稿 this.drafts = await this.main.storyManager.getDrafts(userId) || []; this.collections = await this.main.userManager.getCollections() || []; @@ -93,6 +128,7 @@ export default class ProfileScene extends BaseScene { } } this.calculateMaxScroll(); + this.preloadCoverImages(); } // 刷新草稿列表 @@ -539,20 +575,59 @@ export default class ProfileScene extends BaseScene { // 封面 const coverW = 70, coverH = h - 16; - const colors = this.getGradientColors(index); - const coverGradient = ctx.createLinearGradient(x + 8, y + 8, x + 8 + coverW, y + 8 + coverH); - coverGradient.addColorStop(0, colors[0]); - coverGradient.addColorStop(1, colors[1]); - ctx.fillStyle = coverGradient; - this.roundRect(ctx, x + 8, y + 8, coverW, coverH, 10); - ctx.fill(); + const coverX = x + 8, coverY = y + 8; + const coverUrl = item.coverUrl || item.cover_url; + const coverImg = coverUrl ? this.coverImages[coverUrl] : null; + + // 尝试加载封面图片 + if (coverUrl && this.coverImages[coverUrl] === undefined) { + this.loadCoverImage(coverUrl); + } + + // 绘制封面 + if (coverImg && coverImg !== false) { + // 有封面图片,裁剪绘制 + ctx.save(); + this.roundRect(ctx, coverX, coverY, coverW, coverH, 10); + ctx.clip(); + + // 等比例填充 + const imgRatio = coverImg.width / coverImg.height; + const areaRatio = coverW / coverH; + let drawW, drawH, drawX, drawY; + if (imgRatio > areaRatio) { + drawH = coverH; + drawW = drawH * imgRatio; + drawX = coverX - (drawW - coverW) / 2; + drawY = coverY; + } else { + drawW = coverW; + drawH = drawW / imgRatio; + drawX = coverX; + drawY = coverY - (drawH - coverH) / 2; + } + ctx.drawImage(coverImg, drawX, drawY, drawW, drawH); + ctx.restore(); + } else { + // 无封面图片,使用渐变色 + const colors = this.getGradientColors(index); + const coverGradient = ctx.createLinearGradient(coverX, coverY, coverX + coverW, coverY + coverH); + coverGradient.addColorStop(0, colors[0]); + coverGradient.addColorStop(1, colors[1]); + ctx.fillStyle = coverGradient; + this.roundRect(ctx, coverX, coverY, coverW, coverH, 10); + ctx.fill(); + } // 类型标签 - const typeText = item.draftType === 'continue' ? '续写' : '改写'; - ctx.fillStyle = 'rgba(255,255,255,0.8)'; + const typeText = item.draftType === 'continue' ? '续写' : (item.draftType === 'create' ? '创作' : '改写'); + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + this.roundRect(ctx, coverX, coverY, 32, 18, 6); + ctx.fill(); + ctx.fillStyle = '#ffffff'; ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(typeText, x + 8 + coverW / 2, y + 8 + coverH / 2 + 3); + ctx.fillText(typeText, coverX + 16, coverY + 13); const textX = x + 88; const maxW = w - 100; @@ -618,22 +693,57 @@ export default class ProfileScene extends BaseScene { ctx.fill(); const coverW = 70, coverH = h - 16; - const colors = this.getGradientColors(index); - const coverGradient = ctx.createLinearGradient(x + 8, y + 8, x + 8 + coverW, y + 8 + coverH); - coverGradient.addColorStop(0, colors[0]); - coverGradient.addColorStop(1, colors[1]); - ctx.fillStyle = coverGradient; - this.roundRect(ctx, x + 8, y + 8, coverW, coverH, 10); - ctx.fill(); + const coverX = x + 8, coverY = y + 8; + const coverUrl = item.coverUrl || item.cover_url; + const coverImg = coverUrl ? this.coverImages[coverUrl] : null; + + // 尝试加载封面图片 + if (coverUrl && this.coverImages[coverUrl] === undefined) { + this.loadCoverImage(coverUrl); + } + + // 绘制封面 + if (coverImg && coverImg !== false) { + // 有封面图片,裁剪绘制 + ctx.save(); + this.roundRect(ctx, coverX, coverY, coverW, coverH, 10); + ctx.clip(); + + const imgRatio = coverImg.width / coverImg.height; + const areaRatio = coverW / coverH; + let drawW, drawH, drawX, drawY; + if (imgRatio > areaRatio) { + drawH = coverH; + drawW = drawH * imgRatio; + drawX = coverX - (drawW - coverW) / 2; + drawY = coverY; + } else { + drawW = coverW; + drawH = drawW / imgRatio; + drawX = coverX; + drawY = coverY - (drawH - coverH) / 2; + } + ctx.drawImage(coverImg, drawX, drawY, drawW, drawH); + ctx.restore(); + } else { + // 无封面图片,使用渐变色 + const colors = this.getGradientColors(index); + const coverGradient = ctx.createLinearGradient(coverX, coverY, coverX + coverW, coverY + coverH); + coverGradient.addColorStop(0, colors[0]); + coverGradient.addColorStop(1, colors[1]); + ctx.fillStyle = coverGradient; + this.roundRect(ctx, coverX, coverY, coverW, coverH, 10); + ctx.fill(); + } // AI标签 ctx.fillStyle = '#a855f7'; - this.roundRect(ctx, x + 8, y + 8, 28, 16, 8); + this.roundRect(ctx, coverX, coverY, 28, 16, 8); ctx.fill(); ctx.fillStyle = '#ffffff'; ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; - ctx.fillText('AI', x + 22, y + 19); + ctx.fillText('AI', coverX + 14, coverY + 11); const textX = x + 88; @@ -733,18 +843,52 @@ export default class ProfileScene extends BaseScene { ctx.fill(); const coverW = 60, coverH = h - 16; - const colors = this.getGradientColors(index); - const coverGradient = ctx.createLinearGradient(x + 8, y + 8, x + 8 + coverW, y + 8 + coverH); - coverGradient.addColorStop(0, colors[0]); - coverGradient.addColorStop(1, colors[1]); - ctx.fillStyle = coverGradient; - this.roundRect(ctx, x + 8, y + 8, coverW, coverH, 8); - ctx.fill(); - - ctx.fillStyle = 'rgba(255,255,255,0.85)'; - ctx.font = 'bold 9px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(item.category || '故事', x + 8 + coverW / 2, y + 8 + coverH / 2 + 3); + const coverX = x + 8, coverY = y + 8; + const coverUrl = item.coverUrl || item.cover_url; + const coverImg = coverUrl ? this.coverImages[coverUrl] : null; + + // 尝试加载封面图片 + if (coverUrl && this.coverImages[coverUrl] === undefined) { + this.loadCoverImage(coverUrl); + } + + // 绘制封面 + if (coverImg && coverImg !== false) { + ctx.save(); + this.roundRect(ctx, coverX, coverY, coverW, coverH, 8); + ctx.clip(); + + const imgRatio = coverImg.width / coverImg.height; + const areaRatio = coverW / coverH; + let drawW, drawH, drawX, drawY; + if (imgRatio > areaRatio) { + drawH = coverH; + drawW = drawH * imgRatio; + drawX = coverX - (drawW - coverW) / 2; + drawY = coverY; + } else { + drawW = coverW; + drawH = drawW / imgRatio; + drawX = coverX; + drawY = coverY - (drawH - coverH) / 2; + } + ctx.drawImage(coverImg, drawX, drawY, drawW, drawH); + ctx.restore(); + } else { + const colors = this.getGradientColors(index); + const coverGradient = ctx.createLinearGradient(coverX, coverY, coverX + coverW, coverY + coverH); + coverGradient.addColorStop(0, colors[0]); + coverGradient.addColorStop(1, colors[1]); + ctx.fillStyle = coverGradient; + this.roundRect(ctx, coverX, coverY, coverW, coverH, 8); + ctx.fill(); + + // 无图片时显示分类 + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + ctx.font = 'bold 9px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(item.category || '故事', coverX + coverW / 2, coverY + coverH / 2 + 3); + } const textX = x + 78; @@ -967,7 +1111,11 @@ export default class ProfileScene extends BaseScene { // 检测播放按钮点击(仅已完成状态) if (item.status === 'completed') { if (x >= btnStartX && x <= btnStartX + 50 && relativeY >= btnY && relativeY <= btnY + btnH) { - this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id }); + this.main.sceneManager.switchScene('story', { + storyId: item.storyId, + draftId: item.id, + draftType: item.draftType || item.draft_type + }); return; } @@ -983,7 +1131,11 @@ export default class ProfileScene extends BaseScene { // 点击卡片其他区域 if (item.status === 'completed') { - this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id }); + this.main.sceneManager.switchScene('story', { + storyId: item.storyId, + draftId: item.id, + draftType: item.draftType || item.draft_type + }); } else if (item.status === 'failed') { wx.showToast({ title: 'AI改写失败', icon: 'none' }); } else { @@ -1057,12 +1209,20 @@ export default class ProfileScene extends BaseScene { // 检测播放按钮点击 const playBtnX = padding + cardW - 58; if (x >= playBtnX && x <= playBtnX + 48 && relativeY >= btnY && relativeY <= btnY + btnH) { - this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id }); + this.main.sceneManager.switchScene('story', { + storyId: item.storyId, + draftId: item.id, + draftType: item.draftType || item.draft_type + }); return; } // 点击卡片其他区域也进入播放 - this.main.sceneManager.switchScene('story', { storyId: item.storyId, draftId: item.id }); + this.main.sceneManager.switchScene('story', { + storyId: item.storyId, + draftId: item.id, + draftType: item.draftType || item.draft_type + }); } } } diff --git a/client/js/scenes/StoryScene.js b/client/js/scenes/StoryScene.js index 07c6236..34be092 100644 --- a/client/js/scenes/StoryScene.js +++ b/client/js/scenes/StoryScene.js @@ -9,6 +9,7 @@ export default class StoryScene extends BaseScene { super(main, params); this.storyId = params.storyId; this.draftId = params.draftId || null; // 草稿ID + this.draftType = params.draftType || null; // 草稿类型:'create' | 'rewrite' | 'continue' this.playRecordId = params.playRecordId || null; // 游玩记录ID(从记录回放) this.aiContent = params.aiContent || null; // AI改写内容 this.story = null; @@ -39,6 +40,14 @@ export default class StoryScene extends BaseScene { this.currentCharacterImg = null; // AI改写相关 this.isAIRewriting = false; + this.showRewritePanel = false; // 显示改写面板 + this.rewritePrompt = ''; // 改写输入内容 + this.selectedRewriteTag = -1; // 选中的快捷标签 + this.rewriteTags = ['剧情反转', '主角逆袭', '意外相遇', '真相揭露', '危机来临']; // 快捷标签 + this.rewriteTagRects = []; // 标签位置 + this.rewriteInputRect = null; // 输入框位置 + this.rewriteCancelBtn = null; // 取消按钮位置 + this.rewriteConfirmBtn = null; // 确认按钮位置 // 剧情回顾模式 this.isRecapMode = false; this.recapData = null; @@ -130,7 +139,7 @@ export default class StoryScene extends BaseScene { // 如果是从Draft加载,先获取草稿详情,进入回顾模式 if (this.draftId) { - this.main.showLoading('加载AI改写内容...'); + this.main.showLoading('加载AI内容...'); const draft = await this.main.storyManager.getDraftDetail(this.draftId); @@ -139,9 +148,44 @@ export default class StoryScene extends BaseScene { hasAiNodes: !!draft?.aiNodes, aiNodesKeys: draft?.aiNodes ? Object.keys(draft.aiNodes) : [], entryNodeKey: draft?.entryNodeKey, - pathHistoryLength: draft?.pathHistory?.length + pathHistoryLength: draft?.pathHistory?.length, + draftType: this.draftType })); + // AI创作类型:ai_nodes 包含完整故事,不需要加载原故事 + if (this.draftType === 'create' && draft && draft.aiNodes) { + // 构建虚拟故事对象 + this.story = { + id: this.draftId, + title: draft.title || '未命名故事', + category: draft.aiNodes.category || '冒险', + nodes: draft.aiNodes.nodes || {}, + characters: draft.aiNodes.characters || [] + }; + + // 设置到 storyManager,使选项选择能正常工作 + this.main.storyManager.currentStory = this.story; + this.main.storyManager.pathHistory = []; + + this.setThemeByCategory(this.story.category); + + // 从起始节点开始播放 + const startKey = draft.entryNodeKey || draft.aiNodes.startNodeKey || 'start'; + this.main.storyManager.currentNodeKey = startKey; + this.currentNode = this.story.nodes[startKey]; + + if (this.currentNode) { + this.main.hideLoading(); + this.startTypewriter(this.currentNode.content); + return; + } + + this.main.hideLoading(); + this.main.showError('故事内容加载失败'); + this.main.sceneManager.switchScene('aiCreate'); + return; + } + if (draft && draft.aiNodes && draft.storyId) { // 先加载原故事 this.story = await this.main.storyManager.loadStoryDetail(draft.storyId); @@ -662,11 +706,14 @@ export default class StoryScene extends BaseScene { // 获取背景图 URL let bgUrl; if (isDraftMode) { - // 草稿模式:优先使用节点中的 background_url(需要转成完整URL),否则用草稿路径 + // 草稿模式: + // 1. AI生成的节点有 background_url,使用它 + // 2. 历史节点没有 background_url,使用原故事的图片路径 if (this.currentNode.background_url) { bgUrl = getStaticUrl(this.currentNode.background_url); } else { - bgUrl = getDraftNodeBackground(this.storyId, this.draftId, nodeKey); + // 历史节点使用原故事的背景图 + bgUrl = getNodeBackground(this.storyId, nodeKey); } } else { // 普通模式:使用故事节点路径 @@ -769,6 +816,11 @@ export default class StoryScene extends BaseScene { ctx.fillStyle = `rgba(0, 0, 0, ${this.fadeAlpha})`; ctx.fillRect(0, 0, this.screenWidth, this.screenHeight); } + + // 7. AI改写面板(最顶层) + if (this.showRewritePanel) { + this.renderRewritePanel(ctx); + } } renderSceneBackground(ctx) { @@ -1242,6 +1294,12 @@ export default class StoryScene extends BaseScene { return; } + // AI改写面板的点击处理(最优先) + if (this.showRewritePanel) { + this.handleRewritePanelTouch(x, y); + return; + } + // 回顾模式下的点击处理 if (this.isRecapMode) { // 返回按钮 @@ -1351,6 +1409,25 @@ export default class StoryScene extends BaseScene { return; } + // AI创作模式下,检查当前节点是否是结局(即使没有 is_ending 标记) + if (!this.isReplayMode && this.draftType === 'create' && this.currentNode) { + // 没有选项或 is_ending=true 都视为结局 + if (!this.currentNode.choices || this.currentNode.choices.length === 0 || this.currentNode.is_ending) { + console.log('[AI创作] 到达结局节点:', this.currentNode); + this.main.sceneManager.switchScene('ending', { + storyId: this.storyId, + draftId: this.draftId, + ending: { + name: this.currentNode.ending_name || '故事结局', + type: this.currentNode.ending_type || 'neutral', + content: this.currentNode.content, + score: this.currentNode.ending_score || 70 + } + }); + return; + } + } + // 回放模式下,如果回放路径已用完或到达原结局 if (this.isReplayMode) { const currentNode = this.main.storyManager.getCurrentNode(); @@ -1490,19 +1567,12 @@ export default class StoryScene extends BaseScene { } /** - * 显示AI改写输入框 + * 显示AI改写面板 */ showAIRewriteInput() { - wx.showModal({ - title: 'AI改写剧情', - editable: true, - placeholderText: '输入你的改写指令,如"让主角暴富"', - success: (res) => { - if (res.confirm && res.content) { - this.doAIRewriteAsync(res.content); - } - } - }); + this.showRewritePanel = true; + this.rewritePrompt = ''; + this.selectedRewriteTag = -1; } /** @@ -1553,6 +1623,246 @@ export default class StoryScene extends BaseScene { } } + /** + * 渲染AI改写面板(类似结局页的样式) + */ + renderRewritePanel(ctx) { + const padding = 20; + const panelWidth = this.screenWidth - padding * 2; + const panelHeight = 400; + const panelX = padding; + const panelY = (this.screenHeight - panelHeight) / 2; + + // 遮罩层 + ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'; + ctx.fillRect(0, 0, this.screenWidth, this.screenHeight); + + // 面板背景渐变 + const panelGradient = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelHeight); + panelGradient.addColorStop(0, '#1a1a3e'); + panelGradient.addColorStop(1, '#0d0d1a'); + ctx.fillStyle = panelGradient; + this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20); + ctx.fill(); + + // 面板边框渐变 + const borderGradient = ctx.createLinearGradient(panelX, panelY, panelX + panelWidth, panelY); + borderGradient.addColorStop(0, '#a855f7'); + borderGradient.addColorStop(1, '#ec4899'); + ctx.strokeStyle = borderGradient; + ctx.lineWidth = 2; + this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20); + ctx.stroke(); + + // 标题栏 + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 18px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('✨ AI改写剧情', this.screenWidth / 2, panelY + 35); + + // 副标题 + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('输入你想要的剧情走向,AI将为你重新创作', this.screenWidth / 2, panelY + 58); + + // 分隔线 + const lineGradient = ctx.createLinearGradient(panelX + 20, panelY + 75, panelX + panelWidth - 20, panelY + 75); + lineGradient.addColorStop(0, 'transparent'); + lineGradient.addColorStop(0.5, 'rgba(168,85,247,0.5)'); + lineGradient.addColorStop(1, 'transparent'); + ctx.strokeStyle = lineGradient; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(panelX + 20, panelY + 75); + ctx.lineTo(panelX + panelWidth - 20, panelY + 75); + ctx.stroke(); + + // 快捷标签标题 + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.font = '13px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('快捷选择:', panelX + 15, panelY + 100); + + // 快捷标签 + const tagStartX = panelX + 15; + const tagY = panelY + 115; + const tagHeight = 32; + const tagGap = 8; + let currentX = tagStartX; + let currentY = tagY; + + this.rewriteTagRects = []; + this.rewriteTags.forEach((tag, index) => { + ctx.font = '12px sans-serif'; + const tagWidth = ctx.measureText(tag).width + 24; + + // 换行 + if (currentX + tagWidth > panelX + panelWidth - 15) { + currentX = tagStartX; + currentY += tagHeight + tagGap; + } + + // 标签背景 + const isSelected = index === this.selectedRewriteTag; + if (isSelected) { + const tagGradient = ctx.createLinearGradient(currentX, currentY, currentX + tagWidth, currentY); + tagGradient.addColorStop(0, '#a855f7'); + tagGradient.addColorStop(1, '#ec4899'); + ctx.fillStyle = tagGradient; + } else { + ctx.fillStyle = 'rgba(255,255,255,0.1)'; + } + this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16); + ctx.fill(); + + // 标签边框 + ctx.strokeStyle = isSelected ? 'transparent' : 'rgba(255,255,255,0.2)'; + ctx.lineWidth = 1; + this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16); + ctx.stroke(); + + // 标签文字 + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'center'; + ctx.fillText(tag, currentX + tagWidth / 2, currentY + 21); + + // 存储标签位置 + this.rewriteTagRects.push({ x: currentX, y: currentY, width: tagWidth, height: tagHeight, index }); + + currentX += tagWidth + tagGap; + }); + + // 自定义输入提示 + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.font = '13px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('或自定义输入:', panelX + 15, panelY + 200); + + // 输入框背景 + const inputY = panelY + 215; + const inputHeight = 45; + ctx.fillStyle = 'rgba(255,255,255,0.08)'; + this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; + ctx.lineWidth = 1; + this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12); + ctx.stroke(); + + // 存储输入框位置 + this.rewriteInputRect = { x: panelX + 15, y: inputY, width: panelWidth - 30, height: inputHeight }; + + // 输入框文字或占位符 + ctx.font = '14px sans-serif'; + ctx.textAlign = 'left'; + if (this.rewritePrompt) { + ctx.fillStyle = '#ffffff'; + const displayText = this.rewritePrompt.length > 20 ? this.rewritePrompt.substring(0, 20) + '...' : this.rewritePrompt; + ctx.fillText(displayText, panelX + 28, inputY + 28); + } else { + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.fillText('点击输入你的改写想法...', panelX + 28, inputY + 28); + } + + // 按钮 + const btnY = panelY + panelHeight - 70; + const btnWidth = (panelWidth - 50) / 2; + const btnHeight = 45; + + // 取消按钮 + ctx.fillStyle = 'rgba(255,255,255,0.1)'; + this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; + ctx.lineWidth = 1; + this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22); + ctx.stroke(); + ctx.fillStyle = '#ffffff'; + ctx.font = '15px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('取消', panelX + 15 + btnWidth / 2, btnY + 28); + this.rewriteCancelBtn = { x: panelX + 15, y: btnY, width: btnWidth, height: btnHeight }; + + // 确认按钮 + const confirmGradient = ctx.createLinearGradient(panelX + 35 + btnWidth, btnY, panelX + 35 + btnWidth * 2, btnY); + confirmGradient.addColorStop(0, '#a855f7'); + confirmGradient.addColorStop(1, '#ec4899'); + ctx.fillStyle = confirmGradient; + this.roundRect(ctx, panelX + 35 + btnWidth, btnY, btnWidth, btnHeight, 22); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 15px sans-serif'; + ctx.fillText('✨ 开始改写', panelX + 35 + btnWidth + btnWidth / 2, btnY + 28); + this.rewriteConfirmBtn = { x: panelX + 35 + btnWidth, y: btnY, width: btnWidth, height: btnHeight }; + } + + /** + * 处理改写面板的触摸事件 + */ + handleRewritePanelTouch(x, y) { + // 点击标签 + for (const tag of this.rewriteTagRects) { + if (x >= tag.x && x <= tag.x + tag.width && y >= tag.y && y <= tag.y + tag.height) { + this.selectedRewriteTag = tag.index; + this.rewritePrompt = this.rewriteTags[tag.index]; + return true; + } + } + + // 点击输入框 + if (this.rewriteInputRect) { + const r = this.rewriteInputRect; + if (x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height) { + this.showCustomRewriteInput(); + return true; + } + } + + // 点击取消 + if (this.rewriteCancelBtn) { + const r = this.rewriteCancelBtn; + if (x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height) { + this.showRewritePanel = false; + return true; + } + } + + // 点击确认 + if (this.rewriteConfirmBtn) { + const r = this.rewriteConfirmBtn; + if (x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height) { + if (this.rewritePrompt) { + this.showRewritePanel = false; + this.doAIRewriteAsync(this.rewritePrompt); + } else { + wx.showToast({ title: '请选择或输入改写内容', icon: 'none' }); + } + return true; + } + } + + return false; + } + + /** + * 显示自定义输入弹窗 + */ + showCustomRewriteInput() { + wx.showModal({ + title: '输入改写想法', + editable: true, + placeholderText: '例如:让主角获得逆袭', + content: this.rewritePrompt, + success: (res) => { + if (res.confirm && res.content) { + this.rewritePrompt = res.content; + this.selectedRewriteTag = -1; + } + } + }); + } + destroy() { if (this.main.userManager.isLoggedIn && this.story) { this.main.userManager.saveProgress( diff --git a/server/app/routers/drafts.py b/server/app/routers/drafts.py index de97978..1a7d53f 100644 --- a/server/app/routers/drafts.py +++ b/server/app/routers/drafts.py @@ -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 "" }) diff --git a/server/app/routers/story.py b/server/app/routers/story.py index 430e372..9aa32c0 100644 --- a/server/app/routers/story.py +++ b/server/app/routers/story.py @@ -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 } - } \ No newline at end of file + } + + +# ========== 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() \ No newline at end of file diff --git a/server/app/services/ai.py b/server/app/services/ai.py index 924f347..fc4879e 100644 --- a/server/app/services/ai.py +++ b/server/app/services/ai.py @@ -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_score):good 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)}")