diff --git a/client/js/data/StoryManager.js b/client/js/data/StoryManager.js index 372e34f..204377d 100644 --- a/client/js/data/StoryManager.js +++ b/client/js/data/StoryManager.js @@ -144,4 +144,38 @@ export default class StoryManager { return null; } } + + /** + * AI续写故事 + */ + async continueStory(storyId, prompt) { + try { + const result = await post(`/stories/${storyId}/continue`, { + current_node_key: this.currentNodeKey, + prompt: prompt + }); + return result; + } catch (error) { + console.error('AI续写失败:', error); + return null; + } + } + + /** + * AI创作新故事 + */ + async createStory(params) { + try { + const result = await post('/stories/ai-create', { + genre: params.genre, + keywords: params.keywords, + protagonist: params.protagonist, + conflict: params.conflict + }); + return result; + } catch (error) { + console.error('AI创作失败:', error); + return null; + } + } } diff --git a/client/js/data/UserManager.js b/client/js/data/UserManager.js index f3da458..63ae00d 100644 --- a/client/js/data/UserManager.js +++ b/client/js/data/UserManager.js @@ -131,4 +131,40 @@ export default class UserManager { if (!this.isLoggedIn) return []; return await get('/user/collections', { userId: this.userId }); } + + /** + * 获取最近游玩的故事 + */ + async getRecentPlayed() { + if (!this.isLoggedIn) return []; + try { + return await get('/user/recent-played', { userId: this.userId, limit: 10 }); + } catch (e) { + return []; + } + } + + /** + * 获取AI创作历史 + */ + async getAIHistory() { + if (!this.isLoggedIn) return []; + try { + return await get('/user/ai-history', { userId: this.userId, limit: 20 }); + } catch (e) { + return []; + } + } + + /** + * 获取AI配额 + */ + async getAIQuota() { + if (!this.isLoggedIn) return { daily: 3, used: 0, purchased: 0 }; + try { + return await get('/user/ai-quota', { userId: this.userId }); + } catch (e) { + return { daily: 3, used: 0, purchased: 0 }; + } + } } diff --git a/client/js/scenes/AICreateScene.js b/client/js/scenes/AICreateScene.js new file mode 100644 index 0000000..6d0b774 --- /dev/null +++ b/client/js/scenes/AICreateScene.js @@ -0,0 +1,794 @@ +/** + * AI创作中心场景 + */ +import BaseScene from './BaseScene'; + +export default class AICreateScene extends BaseScene { + constructor(main, params) { + super(main, params); + this.currentTab = 0; // 0:改写 1:续写 2:创作 + this.tabs = ['AI改写', 'AI续写', 'AI创作']; + + // 滚动 + this.scrollY = 0; + this.maxScrollY = 0; + this.isDragging = false; + this.lastTouchY = 0; + this.hasMoved = false; + + // 用户数据 + this.recentStories = []; + this.aiHistory = []; + this.quota = { daily: 3, used: 0, purchased: 0 }; + + // 创作表单 + this.createForm = { + genre: '', + keywords: '', + protagonist: '', + conflict: '' + }; + + // 选中的故事(用于改写/续写) + this.selectedStory = null; + + // 快捷标签 + this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢', '身份揭秘']; + this.continueTags = ['增加悬念', '感情升温', '冲突加剧', '真相大白', '误会解除']; + this.genreTags = ['都市言情', '古风宫廷', '悬疑推理', '校园青春', '修仙玄幻', '职场商战']; + } + + async init() { + await this.loadData(); + } + + async loadData() { + try { + // 加载最近游玩的故事 + this.recentStories = await this.main.userManager.getRecentPlayed() || []; + // 加载AI创作历史 + this.aiHistory = await this.main.userManager.getAIHistory() || []; + // 加载配额 + const quotaData = await this.main.userManager.getAIQuota(); + if (quotaData) this.quota = quotaData; + } catch (e) { + console.error('加载数据失败:', e); + } + this.calculateMaxScroll(); + } + + calculateMaxScroll() { + let contentHeight = 400; + if (this.currentTab === 0 || this.currentTab === 1) { + contentHeight = 300 + this.recentStories.length * 80; + } else { + contentHeight = 600; + } + this.maxScrollY = Math.max(0, contentHeight - this.screenHeight + 200); + } + + update() {} + + render(ctx) { + this.renderBackground(ctx); + this.renderHeader(ctx); + this.renderQuotaBar(ctx); + this.renderTabs(ctx); + this.renderContent(ctx); + } + + renderBackground(ctx) { + const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight); + gradient.addColorStop(0, '#0f0c29'); + gradient.addColorStop(0.5, '#302b63'); + gradient.addColorStop(1, '#24243e'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, this.screenWidth, this.screenHeight); + + // 装饰光效 + const glow = ctx.createRadialGradient(this.screenWidth / 2, 150, 0, this.screenWidth / 2, 150, 200); + glow.addColorStop(0, 'rgba(168, 85, 247, 0.15)'); + glow.addColorStop(1, 'transparent'); + ctx.fillStyle = glow; + ctx.fillRect(0, 0, this.screenWidth, 300); + } + + renderHeader(ctx) { + // 顶部遮罩 + const headerGradient = ctx.createLinearGradient(0, 0, 0, 80); + headerGradient.addColorStop(0, 'rgba(15,12,41,1)'); + headerGradient.addColorStop(1, 'rgba(15,12,41,0)'); + ctx.fillStyle = headerGradient; + ctx.fillRect(0, 0, this.screenWidth, 80); + + // 返回按钮 + ctx.fillStyle = '#ffffff'; + ctx.font = '16px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('‹ 返回', 15, 40); + + // 标题 + ctx.textAlign = 'center'; + ctx.font = 'bold 18px sans-serif'; + const titleGradient = ctx.createLinearGradient(this.screenWidth / 2 - 50, 0, this.screenWidth / 2 + 50, 0); + titleGradient.addColorStop(0, '#a855f7'); + titleGradient.addColorStop(1, '#ec4899'); + ctx.fillStyle = titleGradient; + ctx.fillText('✨ AI创作中心', this.screenWidth / 2, 40); + } + + renderQuotaBar(ctx) { + const barY = 60; + const remaining = this.quota.daily - this.quota.used + this.quota.purchased; + + // 配额背景 + ctx.fillStyle = 'rgba(255,255,255,0.08)'; + this.roundRect(ctx, 15, barY, this.screenWidth - 30, 36, 18); + ctx.fill(); + + // 配额文字 + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(`今日剩余: ${remaining}次`, 30, barY + 23); + + // 充值按钮 + const btnWidth = 70; + const btnX = this.screenWidth - 30 - btnWidth; + const btnGradient = ctx.createLinearGradient(btnX, barY + 5, btnX + btnWidth, barY + 5); + btnGradient.addColorStop(0, '#a855f7'); + btnGradient.addColorStop(1, '#ec4899'); + ctx.fillStyle = btnGradient; + this.roundRect(ctx, btnX, barY + 5, btnWidth, 26, 13); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('获取更多', btnX + btnWidth / 2, barY + 22); + + this.quotaBtnRect = { x: btnX, y: barY + 5, width: btnWidth, height: 26 }; + } + + renderTabs(ctx) { + const tabY = 110; + const tabWidth = (this.screenWidth - 40) / 3; + const padding = 15; + + this.tabRects = []; + this.tabs.forEach((tab, index) => { + const x = padding + index * (tabWidth + 5); + const isActive = index === this.currentTab; + + if (isActive) { + const gradient = ctx.createLinearGradient(x, tabY, x + tabWidth, tabY); + gradient.addColorStop(0, '#a855f7'); + gradient.addColorStop(1, '#ec4899'); + ctx.fillStyle = gradient; + } else { + ctx.fillStyle = 'rgba(255,255,255,0.1)'; + } + this.roundRect(ctx, x, tabY, tabWidth, 36, 18); + ctx.fill(); + + ctx.fillStyle = isActive ? '#ffffff' : 'rgba(255,255,255,0.6)'; + ctx.font = isActive ? 'bold 13px sans-serif' : '13px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(tab, x + tabWidth / 2, tabY + 23); + + this.tabRects.push({ x, y: tabY, width: tabWidth, height: 36, index }); + }); + } + + renderContent(ctx) { + const contentY = 160; + + ctx.save(); + ctx.beginPath(); + ctx.rect(0, contentY, this.screenWidth, this.screenHeight - contentY); + ctx.clip(); + + switch (this.currentTab) { + case 0: + this.renderRewriteTab(ctx, contentY); + break; + case 1: + this.renderContinueTab(ctx, contentY); + break; + case 2: + this.renderCreateTab(ctx, contentY); + break; + } + + ctx.restore(); + } + + renderRewriteTab(ctx, startY) { + const y = startY - this.scrollY; + const padding = 15; + + // 说明文字 + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.font = '13px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('选择一个已玩过的故事,AI帮你改写结局', this.screenWidth / 2, y + 25); + + // 快捷标签 + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('热门改写方向:', padding, y + 60); + + this.renderTags(ctx, this.rewriteTags, padding, y + 75, 'rewrite'); + + // 选择故事 + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.font = '13px sans-serif'; + ctx.fillText('选择要改写的故事:', padding, y + 145); + + this.renderStoryList(ctx, y + 160, 'rewrite'); + } + + renderContinueTab(ctx, startY) { + const y = startY - this.scrollY; + const padding = 15; + + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.font = '13px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('选择一个进行中的故事,AI帮你续写剧情', this.screenWidth / 2, y + 25); + + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('续写方向:', padding, y + 60); + + this.renderTags(ctx, this.continueTags, padding, y + 75, 'continue'); + + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.font = '13px sans-serif'; + ctx.fillText('选择要续写的故事:', padding, y + 145); + + this.renderStoryList(ctx, y + 160, 'continue'); + } + + renderCreateTab(ctx, startY) { + const y = startY - this.scrollY; + const padding = 15; + const inputWidth = this.screenWidth - padding * 2; + + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.font = '13px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('输入关键词,AI为你创作全新故事', this.screenWidth / 2, y + 25); + + // 题材选择 + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.font = '13px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('选择题材:', padding, y + 60); + + this.renderTags(ctx, this.genreTags, padding, y + 75, 'genre'); + + // 关键词输入 + ctx.fillText('故事关键词:', padding, y + 145); + this.renderInputBox(ctx, padding, y + 160, inputWidth, 45, this.createForm.keywords || '例如:霸总、契约婚姻、追妻火葬场', 'keywords'); + + // 主角设定 + ctx.fillText('主角设定:', padding, y + 225); + this.renderInputBox(ctx, padding, y + 240, inputWidth, 45, this.createForm.protagonist || '例如:独立女性设计师', 'protagonist'); + + // 核心冲突 + ctx.fillText('核心冲突:', padding, y + 305); + this.renderInputBox(ctx, padding, y + 320, inputWidth, 45, this.createForm.conflict || '例如:假结婚变真爱', 'conflict'); + + // 开始创作按钮 + const btnY = y + 400; + const btnGradient = ctx.createLinearGradient(padding, btnY, this.screenWidth - padding, btnY); + btnGradient.addColorStop(0, '#a855f7'); + btnGradient.addColorStop(1, '#ec4899'); + ctx.fillStyle = btnGradient; + this.roundRect(ctx, padding, btnY, inputWidth, 50, 25); + ctx.fill(); + + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 16px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('✨ 开始AI创作', this.screenWidth / 2, btnY + 32); + + this.createBtnRect = { x: padding, y: btnY + this.scrollY, width: inputWidth, height: 50 }; + } + + renderTags(ctx, tags, startX, startY, type) { + const tagHeight = 30; + const tagGap = 8; + let currentX = startX; + let currentY = startY; + + if (!this.tagRects) this.tagRects = {}; + this.tagRects[type] = []; + + tags.forEach((tag, index) => { + ctx.font = '12px sans-serif'; + const tagWidth = ctx.measureText(tag).width + 20; + + if (currentX + tagWidth > this.screenWidth - 15) { + currentX = startX; + currentY += tagHeight + tagGap; + } + + const isSelected = (type === 'genre' && this.createForm.genre === tag) || + (type === 'rewrite' && this.selectedRewriteTag === index) || + (type === 'continue' && this.selectedContinueTag === index); + + if (isSelected) { + const gradient = ctx.createLinearGradient(currentX, currentY, currentX + tagWidth, currentY); + gradient.addColorStop(0, '#a855f7'); + gradient.addColorStop(1, '#ec4899'); + ctx.fillStyle = gradient; + } else { + ctx.fillStyle = 'rgba(255,255,255,0.1)'; + } + this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 15); + ctx.fill(); + + if (!isSelected) { + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; + ctx.lineWidth = 1; + this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 15); + ctx.stroke(); + } + + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'center'; + ctx.fillText(tag, currentX + tagWidth / 2, currentY + 20); + + this.tagRects[type].push({ + x: currentX, + y: currentY + this.scrollY, + width: tagWidth, + height: tagHeight, + index, + value: tag + }); + + currentX += tagWidth + tagGap; + }); + } + + renderInputBox(ctx, x, y, width, height, placeholder, field) { + ctx.fillStyle = 'rgba(255,255,255,0.08)'; + this.roundRect(ctx, x, y, width, height, 12); + ctx.fill(); + + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; + ctx.lineWidth = 1; + this.roundRect(ctx, x, y, width, height, 12); + ctx.stroke(); + + ctx.font = '13px sans-serif'; + ctx.textAlign = 'left'; + if (this.createForm[field]) { + ctx.fillStyle = '#ffffff'; + ctx.fillText(this.createForm[field], x + 15, y + height / 2 + 5); + } else { + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.fillText(placeholder, x + 15, y + height / 2 + 5); + } + + if (!this.inputRects) this.inputRects = {}; + this.inputRects[field] = { x, y: y + this.scrollY, width, height, field }; + } + + renderStoryList(ctx, startY, type) { + const padding = 15; + const cardHeight = 70; + const cardGap = 10; + + if (!this.storyRects) this.storyRects = {}; + this.storyRects[type] = []; + + if (this.recentStories.length === 0) { + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.font = '13px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('暂无游玩记录,去首页体验故事吧', this.screenWidth / 2, startY + 40); + return; + } + + this.recentStories.forEach((story, index) => { + const y = startY + index * (cardHeight + cardGap); + const isSelected = this.selectedStory?.id === story.id; + + // 卡片背景 + if (isSelected) { + ctx.fillStyle = 'rgba(168, 85, 247, 0.2)'; + } else { + ctx.fillStyle = 'rgba(255,255,255,0.06)'; + } + this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, cardHeight, 12); + ctx.fill(); + + if (isSelected) { + ctx.strokeStyle = '#a855f7'; + ctx.lineWidth = 2; + this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, cardHeight, 12); + ctx.stroke(); + } + + // 封面占位 + const coverSize = 50; + ctx.fillStyle = 'rgba(255,255,255,0.1)'; + this.roundRect(ctx, padding + 10, y + 10, coverSize, coverSize, 8); + ctx.fill(); + + // 故事标题 + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'left'; + const title = story.title?.length > 12 ? story.title.substring(0, 12) + '...' : story.title; + ctx.fillText(title || '未知故事', padding + 70, y + 28); + + // 分类和进度 + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.font = '11px sans-serif'; + ctx.fillText(`${story.category || '未分类'} · ${story.progress || '进行中'}`, padding + 70, y + 50); + + // 选择按钮 + const btnX = this.screenWidth - padding - 60; + ctx.fillStyle = isSelected ? '#a855f7' : 'rgba(255,255,255,0.2)'; + this.roundRect(ctx, btnX, y + 20, 50, 30, 15); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = '11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(isSelected ? '已选' : '选择', btnX + 25, y + 40); + + this.storyRects[type].push({ + x: padding, + y: y + this.scrollY, + width: this.screenWidth - padding * 2, + height: cardHeight, + story + }); + }); + + // 开始按钮 + if (this.selectedStory) { + const btnY = startY + this.recentStories.length * (cardHeight + cardGap) + 20; + const btnGradient = ctx.createLinearGradient(padding, btnY, this.screenWidth - padding, btnY); + btnGradient.addColorStop(0, '#a855f7'); + btnGradient.addColorStop(1, '#ec4899'); + ctx.fillStyle = btnGradient; + this.roundRect(ctx, padding, btnY, this.screenWidth - padding * 2, 48, 24); + ctx.fill(); + + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 15px sans-serif'; + ctx.textAlign = 'center'; + const btnText = type === 'rewrite' ? '✨ 开始AI改写' : '✨ 开始AI续写'; + ctx.fillText(btnText, this.screenWidth / 2, btnY + 30); + + this.actionBtnRect = { + x: padding, + y: btnY + this.scrollY, + width: this.screenWidth - padding * 2, + height: 48, + type + }; + } + } + + roundRect(ctx, x, y, width, height, radius) { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); + } + + onTouchStart(e) { + const touch = e.touches[0]; + this.lastTouchY = touch.clientY; + this.touchStartY = touch.clientY; + this.hasMoved = false; + if (touch.clientY > 160) { + this.isDragging = true; + } + } + + onTouchMove(e) { + if (!this.isDragging) return; + const touch = e.touches[0]; + const deltaY = this.lastTouchY - touch.clientY; + if (Math.abs(deltaY) > 3) { + this.hasMoved = true; + } + this.scrollY += deltaY; + this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY)); + this.lastTouchY = touch.clientY; + } + + onTouchEnd(e) { + this.isDragging = false; + if (this.hasMoved) return; + + const touch = e.changedTouches[0]; + const x = touch.clientX; + const y = touch.clientY; + + // 返回按钮 + if (y < 60 && x < 80) { + this.main.sceneManager.switchScene('home'); + return; + } + + // 配额按钮 + if (this.quotaBtnRect && this.isInRect(x, y, this.quotaBtnRect)) { + this.showQuotaModal(); + return; + } + + // Tab切换 + if (this.tabRects) { + for (const tab of this.tabRects) { + if (this.isInRect(x, y, tab)) { + if (this.currentTab !== tab.index) { + this.currentTab = tab.index; + this.scrollY = 0; + this.selectedStory = null; + this.calculateMaxScroll(); + } + return; + } + } + } + + // 调整y坐标(考虑滚动) + const scrolledY = y + this.scrollY; + + // 标签点击 + if (this.tagRects) { + const tagType = this.currentTab === 0 ? 'rewrite' : this.currentTab === 1 ? 'continue' : 'genre'; + const tags = this.tagRects[tagType]; + if (tags) { + for (const tag of tags) { + if (this.isInRect(x, scrolledY, tag)) { + this.handleTagSelect(tagType, tag); + return; + } + } + } + } + + // 输入框点击(创作Tab) + if (this.currentTab === 2 && this.inputRects) { + for (const key in this.inputRects) { + const rect = this.inputRects[key]; + if (this.isInRect(x, scrolledY, rect)) { + this.showInputModal(rect.field); + return; + } + } + } + + // 故事列表点击 + if (this.currentTab < 2 && this.storyRects) { + const type = this.currentTab === 0 ? 'rewrite' : 'continue'; + const stories = this.storyRects[type]; + if (stories) { + for (const rect of stories) { + if (this.isInRect(x, scrolledY, rect)) { + this.selectedStory = rect.story; + return; + } + } + } + } + + // 操作按钮 + if (this.actionBtnRect && this.isInRect(x, scrolledY, this.actionBtnRect)) { + this.handleAction(this.actionBtnRect.type); + return; + } + + // 创作按钮 + if (this.currentTab === 2 && this.createBtnRect && this.isInRect(x, scrolledY, this.createBtnRect)) { + this.handleCreate(); + return; + } + } + + isInRect(x, y, rect) { + return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; + } + + handleTagSelect(type, tag) { + if (type === 'genre') { + this.createForm.genre = tag.value; + } else if (type === 'rewrite') { + this.selectedRewriteTag = tag.index; + } else if (type === 'continue') { + this.selectedContinueTag = tag.index; + } + } + + showInputModal(field) { + const titles = { + keywords: '输入故事关键词', + protagonist: '输入主角设定', + conflict: '输入核心冲突' + }; + wx.showModal({ + title: titles[field], + editable: true, + placeholderText: '请输入...', + content: this.createForm[field] || '', + success: (res) => { + if (res.confirm && res.content) { + this.createForm[field] = res.content; + } + } + }); + } + + showQuotaModal() { + wx.showModal({ + title: 'AI创作次数', + content: `今日剩余${this.quota.daily - this.quota.used}次\n购买次数${this.quota.purchased}次\n\n观看广告可获得1次`, + confirmText: '看广告', + success: (res) => { + if (res.confirm) { + this.watchAdForQuota(); + } + } + }); + } + + watchAdForQuota() { + wx.showToast({ title: '获得1次AI次数', icon: 'success' }); + this.quota.purchased += 1; + } + + handleAction(type) { + if (!this.selectedStory) { + wx.showToast({ title: '请先选择故事', icon: 'none' }); + return; + } + + const remaining = this.quota.daily - this.quota.used + this.quota.purchased; + if (remaining <= 0) { + this.showQuotaModal(); + return; + } + + if (type === 'rewrite') { + this.startRewrite(); + } else { + this.startContinue(); + } + } + + startRewrite() { + const tag = this.selectedRewriteTag !== undefined ? this.rewriteTags[this.selectedRewriteTag] : ''; + + wx.showModal({ + title: 'AI改写', + content: '确定要改写这个故事的结局吗?', + editable: true, + placeholderText: tag || '输入改写方向(可选)', + success: async (res) => { + if (res.confirm) { + wx.showLoading({ title: 'AI创作中...' }); + try { + const result = await this.main.storyManager.rewriteEnding( + this.selectedStory.id, + { name: '当前结局', content: '' }, + res.content || tag || '改写结局' + ); + wx.hideLoading(); + if (result) { + this.quota.used += 1; + this.main.sceneManager.switchScene('story', { + storyId: this.selectedStory.id, + aiContent: result + }); + } + } catch (e) { + wx.hideLoading(); + wx.showToast({ title: '创作失败', icon: 'none' }); + } + } + } + }); + } + + startContinue() { + const tag = this.selectedContinueTag !== undefined ? this.continueTags[this.selectedContinueTag] : ''; + + wx.showModal({ + title: 'AI续写', + content: '确定要让AI续写这个故事吗?', + editable: true, + placeholderText: tag || '输入续写方向(可选)', + success: async (res) => { + if (res.confirm) { + wx.showLoading({ title: 'AI创作中...' }); + try { + // TODO: 实现续写API + const result = await this.main.storyManager.continueStory( + this.selectedStory.id, + res.content || tag || '续写剧情' + ); + wx.hideLoading(); + if (result) { + this.quota.used += 1; + this.main.sceneManager.switchScene('story', { + storyId: this.selectedStory.id, + aiContent: result + }); + } + } catch (e) { + wx.hideLoading(); + wx.showToast({ title: '创作失败', icon: 'none' }); + } + } + } + }); + } + + handleCreate() { + if (!this.createForm.genre) { + wx.showToast({ title: '请选择题材', icon: 'none' }); + return; + } + if (!this.createForm.keywords) { + wx.showToast({ title: '请输入关键词', icon: 'none' }); + return; + } + + const remaining = this.quota.daily - this.quota.used + this.quota.purchased; + if (remaining < 5) { + wx.showModal({ + title: '次数不足', + content: 'AI创作需要5次配额,当前剩余' + remaining + '次', + confirmText: '获取更多', + success: (res) => { + if (res.confirm) this.showQuotaModal(); + } + }); + return; + } + + wx.showModal({ + title: '确认创作', + content: `题材:${this.createForm.genre}\n关键词:${this.createForm.keywords}\n\n将消耗5次AI次数`, + success: async (res) => { + if (res.confirm) { + wx.showLoading({ title: 'AI创作中...', mask: true }); + try { + // TODO: 实现完整创作API + const result = await this.main.storyManager.createStory(this.createForm); + wx.hideLoading(); + if (result) { + this.quota.used += 5; + wx.showToast({ title: '创作成功!', icon: 'success' }); + // 跳转到新故事 + setTimeout(() => { + this.main.sceneManager.switchScene('story', { storyId: result.storyId }); + }, 1500); + } + } catch (e) { + wx.hideLoading(); + wx.showToast({ title: '创作失败', icon: 'none' }); + } + } + } + }); + } +} diff --git a/client/js/scenes/HomeScene.js b/client/js/scenes/HomeScene.js index c787b5c..19992c7 100644 --- a/client/js/scenes/HomeScene.js +++ b/client/js/scenes/HomeScene.js @@ -116,6 +116,24 @@ export default class HomeScene extends BaseScene { ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.font = '13px sans-serif'; ctx.fillText('每个选择,都是一个新世界', 20, 75); + + // AI创作入口按钮 + const btnWidth = 80; + const btnHeight = 32; + const btnX = this.screenWidth - btnWidth - 15; + const btnY = 35; + const btnGradient = ctx.createLinearGradient(btnX, btnY, btnX + btnWidth, btnY); + btnGradient.addColorStop(0, '#a855f7'); + btnGradient.addColorStop(1, '#ec4899'); + ctx.fillStyle = btnGradient; + this.roundRect(ctx, btnX, btnY, btnWidth, btnHeight, 16); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 12px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('✨ AI创作', btnX + btnWidth / 2, btnY + 21); + + this.aiCreateBtnRect = { x: btnX, y: btnY, width: btnWidth, height: btnHeight }; } renderCategories(ctx) { @@ -470,6 +488,15 @@ export default class HomeScene extends BaseScene { const x = touch.clientX; const y = touch.clientY; + // 检测AI创作按钮点击 + if (this.aiCreateBtnRect) { + const btn = this.aiCreateBtnRect; + if (x >= btn.x && x <= btn.x + btn.width && y >= btn.y && y <= btn.y + btn.height) { + this.main.sceneManager.switchScene('aiCreate'); + return; + } + } + // 检测Tab栏点击 if (y > this.screenHeight - 65) { const tabWidth = this.screenWidth / 3; diff --git a/client/js/scenes/SceneManager.js b/client/js/scenes/SceneManager.js index 3905377..9edb636 100644 --- a/client/js/scenes/SceneManager.js +++ b/client/js/scenes/SceneManager.js @@ -6,6 +6,7 @@ import StoryScene from './StoryScene'; import EndingScene from './EndingScene'; import ProfileScene from './ProfileScene'; import ChapterScene from './ChapterScene'; +import AICreateScene from './AICreateScene'; export default class SceneManager { constructor(main) { @@ -16,7 +17,8 @@ export default class SceneManager { story: StoryScene, ending: EndingScene, profile: ProfileScene, - chapter: ChapterScene + chapter: ChapterScene, + aiCreate: AICreateScene }; } diff --git a/docs/AI创作系统设计.md b/docs/AI创作系统设计.md new file mode 100644 index 0000000..66ef640 --- /dev/null +++ b/docs/AI创作系统设计.md @@ -0,0 +1,396 @@ +# AI创作系统设计文档 + +## 一、功能矩阵 + +| 功能 | 入口 | 输入 | 输出 | 配额消耗 | +|-----|------|------|------|---------| +| AI改写结局 | 结局页 | 原结局+用户指令 | 新结局文本 | 1次 | +| AI改写节点 | 章节选择页 | 原节点+用户指令 | 新节点+选项 | 1次 | +| AI续写 | 故事播放页 | 当前节点+用户指令 | 后续2-3个节点 | 2次 | +| AI创作大纲 | 创作中心 | 题材/关键词 | 标题+简介+大纲 | 1次 | +| AI完整创作 | 创作中心 | 大纲确认 | 完整故事节点树 | 5次 | +| AI润色 | 编辑器 | 原文本 | 优化后文本 | 1次 | + +--- + +## 二、核心流程 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ AI创作完整流程 │ +└─────────────────────────────────────────────────────────────────┘ + +用户触发 ──► 配额检查 ──► 构建Prompt ──► 调用AI ──► 解析响应 ──► 存储记录 + │ │ │ │ + │ ▼ ▼ ▼ + │ 配额不足? [ai_generations] 解析失败? + │ │ 记录调用 │ + │ ▼ ▼ + │ 引导充值/看广告 重试/降级 + │ + ▼ +展示结果 ◄── 用户操作 ──► 采纳? ──► 写入故事表 ──► 进入审核流程 + │ + ▼ + 放弃/编辑 +``` + +--- + +## 三、Prompt模板设计 + +### 3.1 改写结局 +``` +[系统提示] +你是一个互动故事创作专家。根据用户的改写指令,重新创作故事结局。 +要求: +- 保持原故事的世界观和人物性格 +- 结局要有张力和情感冲击 +- 字数控制在200-400字 +- 输出格式:纯文本 + +[用户提示] +故事标题:{title} +故事分类:{category} +原结局名称:{ending_name} +原结局内容:{ending_content} +--- +用户改写指令:{user_prompt} +--- +请创作新的结局: +``` + +### 3.2 续写剧情 +``` +[系统提示] +你是一个互动故事创作专家。根据当前剧情,续写后续发展。 +要求: +- 提供2-3个剧情走向选项 +- 每个选项后续写1个节点内容 +- 保持悬念和代入感 +- 输出JSON格式 + +[用户提示] +故事标题:{title} +当前剧情:{current_content} +已做选择:{choices_history} +--- +用户期望:{user_prompt} +--- +请续写剧情,输出格式: +{ + "choices": [ + {"text": "选项1文本", "content": "选择后的剧情内容", "speaker": "角色名"}, + {"text": "选项2文本", "content": "选择后的剧情内容", "speaker": "角色名"} + ] +} +``` + +### 3.3 完整创作 +``` +[系统提示] +你是一个互动故事创作专家。根据用户提供的关键词,创作一个完整的互动故事。 +要求: +- 故事有3-5个关键分支点 +- 至少2个不同结局(好结局/坏结局) +- 每个节点100-200字 +- 输出完整的节点树JSON + +[用户提示] +题材:{genre} +关键词:{keywords} +主角设定:{protagonist} +核心冲突:{conflict} +--- +请创作完整故事,输出格式: +{ + "title": "故事标题", + "description": "故事简介", + "nodes": { + "start": {"content": "开头内容", "speaker": "", "choices": [...]}, + "node_1": {...}, + "ending_good": {"content": "好结局", "is_ending": true, "ending_type": "good"}, + "ending_bad": {"content": "坏结局", "is_ending": true, "ending_type": "bad"} + } +} +``` + +--- + +## 四、API设计 + +### 4.1 AI改写结局 +``` +POST /api/ai/rewrite-ending +Request: +{ + "story_id": 123, + "ending_name": "双向奔赴", + "ending_content": "原结局内容...", + "prompt": "让主角逆袭" +} + +Response: +{ + "code": 0, + "data": { + "generation_id": 456, + "content": "新结局内容...", + "ending_name": "双向奔赴(改写版)", + "tokens_used": 580, + "quota_remaining": 4 + } +} +``` + +### 4.2 AI续写 +``` +POST /api/ai/continue +Request: +{ + "story_id": 123, + "current_node_key": "node_5", + "choices_history": ["选项A", "选项B"], + "prompt": "希望有反转" +} + +Response: +{ + "code": 0, + "data": { + "generation_id": 457, + "choices": [ + {"text": "追上去", "content": "你快步追上...", "next_key": "ai_node_1"}, + {"text": "放手离开", "content": "你转身离去...", "next_key": "ai_node_2"} + ], + "nodes": { + "ai_node_1": {"content": "...", "choices": [...]}, + "ai_node_2": {"content": "...", "is_ending": true} + } + } +} +``` + +### 4.3 AI完整创作 +``` +POST /api/ai/create +Request: +{ + "genre": "都市言情", + "keywords": "霸总,契约婚姻,追妻火葬场", + "protagonist": "独立女性设计师", + "conflict": "假结婚变真爱" +} + +Response: +{ + "code": 0, + "data": { + "generation_id": 458, + "draft_story_id": 789, + "title": "契约总裁的心动法则", + "description": "...", + "node_count": 12, + "ending_count": 3 + } +} +``` + +### 4.4 配额查询 +``` +GET /api/user/ai-quota + +Response: +{ + "code": 0, + "data": { + "daily_free_remaining": 3, + "purchased_remaining": 10, + "vip_bonus": 5, + "total_available": 18, + "reset_time": "2026-03-04 00:00:00" + } +} +``` + +--- + +## 五、配额与计费 + +### 5.1 配额规则 +| 用户类型 | 每日免费 | 购买包 | 说明 | +|---------|---------|-------|------| +| 普通用户 | 3次 | 10次/6元 | 看广告+1次 | +| 月卡VIP | 10次 | 同上 | 月费18元 | +| 年卡VIP | 20次 | 同上 | 年费168元 | + +### 5.2 消耗逻辑 +```javascript +// 扣费优先级:每日免费 > 赠送 > 购买 > VIP额外 +async function consumeQuota(userId, amount = 1) { + const quota = await UserAIQuota.findByPk(userId); + + // 检查是否需要重置每日配额 + if (quota.daily_reset_date !== today) { + quota.daily_free_used = 0; + quota.daily_reset_date = today; + } + + // 计算可用配额 + const dailyFreeRemain = quota.daily_free_total - quota.daily_free_used; + const available = dailyFreeRemain + quota.gift_quota + quota.purchased_quota + quota.vip_daily_bonus; + + if (available < amount) { + throw new Error('QUOTA_INSUFFICIENT'); + } + + // 按优先级扣除 + let toConsume = amount; + if (dailyFreeRemain > 0) { + const use = Math.min(toConsume, dailyFreeRemain); + quota.daily_free_used += use; + toConsume -= use; + } + // ... 依次扣除其他配额 + + await quota.save(); +} +``` + +--- + +## 六、审核流程 + +``` +用户发布 ──► 机器审核 ──► 通过?──► 直接上架 + │ │ + ▼ ▼ + 疑似违规 人工审核 ──► 通过/拒绝 + │ + ▼ + 进入人工队列 +``` + +### 6.1 机器审核维度 +- 敏感词检测(sensitive_words表) +- 内容安全API(腾讯云/阿里云) +- AI生成内容标记检测 + +### 6.2 审核状态流转 +``` +草稿(0) ──► 提交审核 ──► 审核中(1) ──► 已发布(2) + │ + ▼ + 已拒绝(4) ──► 修改后重新提交 +``` + +--- + +## 七、数据统计 + +### 7.1 核心指标 +- AI调用成功率 +- 平均响应时间 +- 用户采纳率 +- 生成内容发布率 +- Token消耗/成本 + +### 7.2 日报表聚合 +```sql +-- 每日定时任务聚合到 ai_daily_stats +INSERT INTO ai_daily_stats (stat_date, gen_type, model_name, call_count, ...) +SELECT + DATE(created_at), + gen_type, + model_name, + COUNT(*), + SUM(CASE WHEN status=1 THEN 1 ELSE 0 END), + SUM(input_tokens), + SUM(output_tokens), + AVG(latency_ms) +FROM ai_generations +WHERE DATE(created_at) = CURDATE() - INTERVAL 1 DAY +GROUP BY DATE(created_at), gen_type, model_name; +``` + +--- + +## 八、技术实现要点 + +### 8.1 服务端架构 +``` +┌─────────────────────────────────────────────┐ +│ API Gateway │ +└──────────────────────┬──────────────────────┘ + │ + ┌──────────────┼──────────────┐ + ▼ ▼ ▼ + ┌─────────┐ ┌─────────┐ ┌─────────┐ + │ 故事服务 │ │ AI服务 │ │ 用户服务 │ + └─────────┘ └────┬────┘ └─────────┘ + │ + ┌────────────┼────────────┐ + ▼ ▼ ▼ + ┌─────────┐ ┌─────────┐ ┌─────────┐ + │ OpenAI │ │ Claude │ │ 本地模型 │ + └─────────┘ └─────────┘ └─────────┘ +``` + +### 8.2 AI服务封装 +```javascript +// services/ai.js +class AIService { + constructor() { + this.providers = { + openai: new OpenAIProvider(), + claude: new ClaudeProvider(), + local: new LocalProvider() + }; + } + + async generate(options) { + const { type, provider = 'openai', ...params } = options; + const template = await this.getPromptTemplate(type); + const prompt = this.buildPrompt(template, params); + + const startTime = Date.now(); + try { + const result = await this.providers[provider].chat(prompt); + return { + success: true, + content: result.content, + tokens: result.usage, + latency: Date.now() - startTime + }; + } catch (error) { + return { success: false, error: error.message }; + } + } +} +``` + +### 8.3 前端交互优化 +- 流式输出:使用SSE实时展示生成过程 +- 骨架屏:生成中显示打字动画 +- 失败重试:自动重试2次,超时30秒 +- 结果缓存:相同输入5分钟内复用 + +--- + +## 九、后续迭代 + +### Phase 1(当前) +- [x] AI改写结局 +- [ ] 配额系统接入 +- [ ] 基础审核流程 + +### Phase 2 +- [ ] AI续写功能 +- [ ] AI创作大纲 +- [ ] 创作中心入口 + +### Phase 3 +- [ ] AI完整创作 +- [ ] UGC发布流程 +- [ ] 创作者认证 +- [ ] 收益分成