feat: AI改写面板样式优化、封面图片显示、max_tokens调整至8192

This commit is contained in:
wangwuww111
2026-03-16 16:35:59 +08:00
parent 253bc4aed2
commit d111f1a2cf
5 changed files with 1352 additions and 61 deletions

View File

@@ -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 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(x + 8, y + 8, x + 8 + coverW, y + 8 + coverH);
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, x + 8, y + 8, coverW, coverH, 10);
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 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(x + 8, y + 8, x + 8 + coverW, y + 8 + coverH);
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, x + 8, y + 8, coverW, coverH, 10);
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 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(x + 8, y + 8, x + 8 + coverW, y + 8 + coverH);
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, x + 8, y + 8, coverW, coverH, 8);
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 || '故事', x + 8 + coverW / 2, y + 8 + coverH / 2 + 3);
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
});
}
}
}

View File

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

View File

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

View File

@@ -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("")
@@ -838,3 +848,436 @@ async def generate_all_story_images(
"failed": failed
}
}
# ========== 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()

View File

@@ -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_scoregood 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)}")