feat: AI改写面板样式优化、封面图片显示、max_tokens调整至8192
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
* 个人中心场景 - 支持创作者功能
|
* 个人中心场景 - 支持创作者功能
|
||||||
*/
|
*/
|
||||||
import BaseScene from './BaseScene';
|
import BaseScene from './BaseScene';
|
||||||
|
import { getStaticUrl } from '../utils/http';
|
||||||
|
|
||||||
export default class ProfileScene extends BaseScene {
|
export default class ProfileScene extends BaseScene {
|
||||||
constructor(main, params) {
|
constructor(main, params) {
|
||||||
@@ -40,6 +41,9 @@ export default class ProfileScene extends BaseScene {
|
|||||||
this.lastTouchY = 0;
|
this.lastTouchY = 0;
|
||||||
this.scrollVelocity = 0;
|
this.scrollVelocity = 0;
|
||||||
this.hasMoved = false;
|
this.hasMoved = false;
|
||||||
|
|
||||||
|
// 封面图片缓存
|
||||||
|
this.coverImages = {}; // { url: Image对象 }
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
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() {
|
async loadData() {
|
||||||
if (this.main.userManager.isLoggedIn) {
|
if (this.main.userManager.isLoggedIn) {
|
||||||
try {
|
try {
|
||||||
const userId = this.main.userManager.userId;
|
const userId = this.main.userManager.userId;
|
||||||
// 加载已发布到创作中心的作品(改写+续写)
|
// 加载已发布到创作中心的作品(改写+续写+创作)
|
||||||
const publishedRewrites = await this.main.userManager.getPublishedDrafts('rewrite') || [];
|
const publishedRewrites = await this.main.userManager.getPublishedDrafts('rewrite') || [];
|
||||||
const publishedContinues = await this.main.userManager.getPublishedDrafts('continue') || [];
|
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 改写草稿
|
// 加载 AI 改写草稿
|
||||||
this.drafts = await this.main.storyManager.getDrafts(userId) || [];
|
this.drafts = await this.main.storyManager.getDrafts(userId) || [];
|
||||||
this.collections = await this.main.userManager.getCollections() || [];
|
this.collections = await this.main.userManager.getCollections() || [];
|
||||||
@@ -93,6 +128,7 @@ export default class ProfileScene extends BaseScene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.calculateMaxScroll();
|
this.calculateMaxScroll();
|
||||||
|
this.preloadCoverImages();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新草稿列表
|
// 刷新草稿列表
|
||||||
@@ -539,20 +575,59 @@ export default class ProfileScene extends BaseScene {
|
|||||||
|
|
||||||
// 封面
|
// 封面
|
||||||
const coverW = 70, coverH = h - 16;
|
const coverW = 70, coverH = h - 16;
|
||||||
const colors = this.getGradientColors(index);
|
const coverX = x + 8, coverY = y + 8;
|
||||||
const coverGradient = ctx.createLinearGradient(x + 8, y + 8, x + 8 + coverW, y + 8 + coverH);
|
const coverUrl = item.coverUrl || item.cover_url;
|
||||||
coverGradient.addColorStop(0, colors[0]);
|
const coverImg = coverUrl ? this.coverImages[coverUrl] : null;
|
||||||
coverGradient.addColorStop(1, colors[1]);
|
|
||||||
ctx.fillStyle = coverGradient;
|
// 尝试加载封面图片
|
||||||
this.roundRect(ctx, x + 8, y + 8, coverW, coverH, 10);
|
if (coverUrl && this.coverImages[coverUrl] === undefined) {
|
||||||
ctx.fill();
|
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' ? '续写' : '改写';
|
const typeText = item.draftType === 'continue' ? '续写' : (item.draftType === 'create' ? '创作' : '改写');
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
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.font = 'bold 9px sans-serif';
|
||||||
ctx.textAlign = 'center';
|
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 textX = x + 88;
|
||||||
const maxW = w - 100;
|
const maxW = w - 100;
|
||||||
@@ -618,22 +693,57 @@ export default class ProfileScene extends BaseScene {
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
const coverW = 70, coverH = h - 16;
|
const coverW = 70, coverH = h - 16;
|
||||||
const colors = this.getGradientColors(index);
|
const coverX = x + 8, coverY = y + 8;
|
||||||
const coverGradient = ctx.createLinearGradient(x + 8, y + 8, x + 8 + coverW, y + 8 + coverH);
|
const coverUrl = item.coverUrl || item.cover_url;
|
||||||
coverGradient.addColorStop(0, colors[0]);
|
const coverImg = coverUrl ? this.coverImages[coverUrl] : null;
|
||||||
coverGradient.addColorStop(1, colors[1]);
|
|
||||||
ctx.fillStyle = coverGradient;
|
// 尝试加载封面图片
|
||||||
this.roundRect(ctx, x + 8, y + 8, coverW, coverH, 10);
|
if (coverUrl && this.coverImages[coverUrl] === undefined) {
|
||||||
ctx.fill();
|
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标签
|
// AI标签
|
||||||
ctx.fillStyle = '#a855f7';
|
ctx.fillStyle = '#a855f7';
|
||||||
this.roundRect(ctx, x + 8, y + 8, 28, 16, 8);
|
this.roundRect(ctx, coverX, coverY, 28, 16, 8);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.font = 'bold 9px sans-serif';
|
ctx.font = 'bold 9px sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText('AI', x + 22, y + 19);
|
ctx.fillText('AI', coverX + 14, coverY + 11);
|
||||||
|
|
||||||
const textX = x + 88;
|
const textX = x + 88;
|
||||||
|
|
||||||
@@ -733,18 +843,52 @@ export default class ProfileScene extends BaseScene {
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
const coverW = 60, coverH = h - 16;
|
const coverW = 60, coverH = h - 16;
|
||||||
const colors = this.getGradientColors(index);
|
const coverX = x + 8, coverY = y + 8;
|
||||||
const coverGradient = ctx.createLinearGradient(x + 8, y + 8, x + 8 + coverW, y + 8 + coverH);
|
const coverUrl = item.coverUrl || item.cover_url;
|
||||||
coverGradient.addColorStop(0, colors[0]);
|
const coverImg = coverUrl ? this.coverImages[coverUrl] : null;
|
||||||
coverGradient.addColorStop(1, colors[1]);
|
|
||||||
ctx.fillStyle = coverGradient;
|
// 尝试加载封面图片
|
||||||
this.roundRect(ctx, x + 8, y + 8, coverW, coverH, 8);
|
if (coverUrl && this.coverImages[coverUrl] === undefined) {
|
||||||
ctx.fill();
|
this.loadCoverImage(coverUrl);
|
||||||
|
}
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.85)';
|
|
||||||
ctx.font = 'bold 9px sans-serif';
|
// 绘制封面
|
||||||
ctx.textAlign = 'center';
|
if (coverImg && coverImg !== false) {
|
||||||
ctx.fillText(item.category || '故事', x + 8 + coverW / 2, y + 8 + coverH / 2 + 3);
|
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;
|
const textX = x + 78;
|
||||||
|
|
||||||
@@ -967,7 +1111,11 @@ export default class ProfileScene extends BaseScene {
|
|||||||
// 检测播放按钮点击(仅已完成状态)
|
// 检测播放按钮点击(仅已完成状态)
|
||||||
if (item.status === 'completed') {
|
if (item.status === 'completed') {
|
||||||
if (x >= btnStartX && x <= btnStartX + 50 && relativeY >= btnY && relativeY <= btnY + btnH) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,7 +1131,11 @@ export default class ProfileScene extends BaseScene {
|
|||||||
|
|
||||||
// 点击卡片其他区域
|
// 点击卡片其他区域
|
||||||
if (item.status === 'completed') {
|
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') {
|
} else if (item.status === 'failed') {
|
||||||
wx.showToast({ title: 'AI改写失败', icon: 'none' });
|
wx.showToast({ title: 'AI改写失败', icon: 'none' });
|
||||||
} else {
|
} else {
|
||||||
@@ -1057,12 +1209,20 @@ export default class ProfileScene extends BaseScene {
|
|||||||
// 检测播放按钮点击
|
// 检测播放按钮点击
|
||||||
const playBtnX = padding + cardW - 58;
|
const playBtnX = padding + cardW - 58;
|
||||||
if (x >= playBtnX && x <= playBtnX + 48 && relativeY >= btnY && relativeY <= btnY + btnH) {
|
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;
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default class StoryScene extends BaseScene {
|
|||||||
super(main, params);
|
super(main, params);
|
||||||
this.storyId = params.storyId;
|
this.storyId = params.storyId;
|
||||||
this.draftId = params.draftId || null; // 草稿ID
|
this.draftId = params.draftId || null; // 草稿ID
|
||||||
|
this.draftType = params.draftType || null; // 草稿类型:'create' | 'rewrite' | 'continue'
|
||||||
this.playRecordId = params.playRecordId || null; // 游玩记录ID(从记录回放)
|
this.playRecordId = params.playRecordId || null; // 游玩记录ID(从记录回放)
|
||||||
this.aiContent = params.aiContent || null; // AI改写内容
|
this.aiContent = params.aiContent || null; // AI改写内容
|
||||||
this.story = null;
|
this.story = null;
|
||||||
@@ -39,6 +40,14 @@ export default class StoryScene extends BaseScene {
|
|||||||
this.currentCharacterImg = null;
|
this.currentCharacterImg = null;
|
||||||
// AI改写相关
|
// AI改写相关
|
||||||
this.isAIRewriting = false;
|
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.isRecapMode = false;
|
||||||
this.recapData = null;
|
this.recapData = null;
|
||||||
@@ -130,7 +139,7 @@ export default class StoryScene extends BaseScene {
|
|||||||
|
|
||||||
// 如果是从Draft加载,先获取草稿详情,进入回顾模式
|
// 如果是从Draft加载,先获取草稿详情,进入回顾模式
|
||||||
if (this.draftId) {
|
if (this.draftId) {
|
||||||
this.main.showLoading('加载AI改写内容...');
|
this.main.showLoading('加载AI内容...');
|
||||||
|
|
||||||
const draft = await this.main.storyManager.getDraftDetail(this.draftId);
|
const draft = await this.main.storyManager.getDraftDetail(this.draftId);
|
||||||
|
|
||||||
@@ -139,9 +148,44 @@ export default class StoryScene extends BaseScene {
|
|||||||
hasAiNodes: !!draft?.aiNodes,
|
hasAiNodes: !!draft?.aiNodes,
|
||||||
aiNodesKeys: draft?.aiNodes ? Object.keys(draft.aiNodes) : [],
|
aiNodesKeys: draft?.aiNodes ? Object.keys(draft.aiNodes) : [],
|
||||||
entryNodeKey: draft?.entryNodeKey,
|
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) {
|
if (draft && draft.aiNodes && draft.storyId) {
|
||||||
// 先加载原故事
|
// 先加载原故事
|
||||||
this.story = await this.main.storyManager.loadStoryDetail(draft.storyId);
|
this.story = await this.main.storyManager.loadStoryDetail(draft.storyId);
|
||||||
@@ -662,11 +706,14 @@ export default class StoryScene extends BaseScene {
|
|||||||
// 获取背景图 URL
|
// 获取背景图 URL
|
||||||
let bgUrl;
|
let bgUrl;
|
||||||
if (isDraftMode) {
|
if (isDraftMode) {
|
||||||
// 草稿模式:优先使用节点中的 background_url(需要转成完整URL),否则用草稿路径
|
// 草稿模式:
|
||||||
|
// 1. AI生成的节点有 background_url,使用它
|
||||||
|
// 2. 历史节点没有 background_url,使用原故事的图片路径
|
||||||
if (this.currentNode.background_url) {
|
if (this.currentNode.background_url) {
|
||||||
bgUrl = getStaticUrl(this.currentNode.background_url);
|
bgUrl = getStaticUrl(this.currentNode.background_url);
|
||||||
} else {
|
} else {
|
||||||
bgUrl = getDraftNodeBackground(this.storyId, this.draftId, nodeKey);
|
// 历史节点使用原故事的背景图
|
||||||
|
bgUrl = getNodeBackground(this.storyId, nodeKey);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 普通模式:使用故事节点路径
|
// 普通模式:使用故事节点路径
|
||||||
@@ -769,6 +816,11 @@ export default class StoryScene extends BaseScene {
|
|||||||
ctx.fillStyle = `rgba(0, 0, 0, ${this.fadeAlpha})`;
|
ctx.fillStyle = `rgba(0, 0, 0, ${this.fadeAlpha})`;
|
||||||
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. AI改写面板(最顶层)
|
||||||
|
if (this.showRewritePanel) {
|
||||||
|
this.renderRewritePanel(ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSceneBackground(ctx) {
|
renderSceneBackground(ctx) {
|
||||||
@@ -1242,6 +1294,12 @@ export default class StoryScene extends BaseScene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI改写面板的点击处理(最优先)
|
||||||
|
if (this.showRewritePanel) {
|
||||||
|
this.handleRewritePanelTouch(x, y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 回顾模式下的点击处理
|
// 回顾模式下的点击处理
|
||||||
if (this.isRecapMode) {
|
if (this.isRecapMode) {
|
||||||
// 返回按钮
|
// 返回按钮
|
||||||
@@ -1351,6 +1409,25 @@ export default class StoryScene extends BaseScene {
|
|||||||
return;
|
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) {
|
if (this.isReplayMode) {
|
||||||
const currentNode = this.main.storyManager.getCurrentNode();
|
const currentNode = this.main.storyManager.getCurrentNode();
|
||||||
@@ -1490,19 +1567,12 @@ export default class StoryScene extends BaseScene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示AI改写输入框
|
* 显示AI改写面板
|
||||||
*/
|
*/
|
||||||
showAIRewriteInput() {
|
showAIRewriteInput() {
|
||||||
wx.showModal({
|
this.showRewritePanel = true;
|
||||||
title: 'AI改写剧情',
|
this.rewritePrompt = '';
|
||||||
editable: true,
|
this.selectedRewriteTag = -1;
|
||||||
placeholderText: '输入你的改写指令,如"让主角暴富"',
|
|
||||||
success: (res) => {
|
|
||||||
if (res.confirm && res.content) {
|
|
||||||
this.doAIRewriteAsync(res.content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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() {
|
destroy() {
|
||||||
if (this.main.userManager.isLoggedIn && this.story) {
|
if (this.main.userManager.isLoggedIn && this.story) {
|
||||||
this.main.userManager.saveProgress(
|
this.main.userManager.saveProgress(
|
||||||
|
|||||||
@@ -678,7 +678,7 @@ async def get_drafts(
|
|||||||
):
|
):
|
||||||
"""获取用户的草稿列表"""
|
"""获取用户的草稿列表"""
|
||||||
result = await db.execute(
|
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)
|
.join(Story, StoryDraft.story_id == Story.id)
|
||||||
.where(StoryDraft.user_id == userId)
|
.where(StoryDraft.user_id == userId)
|
||||||
.order_by(StoryDraft.created_at.desc())
|
.order_by(StoryDraft.created_at.desc())
|
||||||
@@ -688,6 +688,15 @@ async def get_drafts(
|
|||||||
for row in result:
|
for row in result:
|
||||||
draft = row[0]
|
draft = row[0]
|
||||||
story_title = row[1]
|
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({
|
drafts.append({
|
||||||
"id": draft.id,
|
"id": draft.id,
|
||||||
"storyId": draft.story_id,
|
"storyId": draft.story_id,
|
||||||
@@ -698,6 +707,7 @@ async def get_drafts(
|
|||||||
"isRead": draft.is_read,
|
"isRead": draft.is_read,
|
||||||
"publishedToCenter": draft.published_to_center,
|
"publishedToCenter": draft.published_to_center,
|
||||||
"draftType": draft.draft_type or "rewrite",
|
"draftType": draft.draft_type or "rewrite",
|
||||||
|
"coverUrl": cover_url,
|
||||||
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else "",
|
"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
|
"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,
|
draftType: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db)
|
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
|
Story, StoryDraft.story_id == Story.id
|
||||||
).where(
|
).where(
|
||||||
StoryDraft.user_id == userId,
|
StoryDraft.user_id == userId,
|
||||||
StoryDraft.published_to_center == True,
|
|
||||||
StoryDraft.status == DraftStatus.completed
|
StoryDraft.status == DraftStatus.completed
|
||||||
)
|
)
|
||||||
|
|
||||||
# 按类型筛选
|
# 按类型筛选
|
||||||
if draftType:
|
if draftType:
|
||||||
query = query.where(StoryDraft.draft_type == 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())
|
query = query.order_by(StoryDraft.created_at.desc())
|
||||||
|
|
||||||
@@ -764,14 +781,25 @@ async def get_published_drafts(
|
|||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
drafts = []
|
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({
|
drafts.append({
|
||||||
"id": draft.id,
|
"id": draft.id,
|
||||||
|
"story_id": draft.story_id, # 添加 story_id 字段
|
||||||
"storyId": draft.story_id,
|
"storyId": draft.story_id,
|
||||||
"storyTitle": story_title or "未知故事",
|
"storyTitle": story_title or "未知故事",
|
||||||
"title": draft.title or "",
|
"title": draft.title or "",
|
||||||
"userPrompt": draft.user_prompt,
|
"userPrompt": draft.user_prompt,
|
||||||
"draftType": draft.draft_type or "rewrite",
|
"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 ""
|
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
故事相关API路由
|
故事相关API路由
|
||||||
"""
|
"""
|
||||||
import random
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, update, func, distinct
|
from sqlalchemy import select, update, func, distinct
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.database import get_db
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -65,6 +66,15 @@ class GenerateImageRequest(BaseModel):
|
|||||||
targetKey: Optional[str] = None # nodeKey 或 characterId
|
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接口 ==========
|
# ========== API接口 ==========
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -837,4 +847,437 @@ async def generate_all_story_images(
|
|||||||
"generated": generated,
|
"generated": generated,
|
||||||
"failed": failed
|
"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()
|
||||||
@@ -453,6 +453,356 @@ class AIService:
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return None
|
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]:
|
def _parse_branch_json(self, content: str) -> Optional[Dict]:
|
||||||
"""解析AI返回的分支JSON"""
|
"""解析AI返回的分支JSON"""
|
||||||
print(f"[_parse_branch_json] 开始解析,内容长度={len(content)}")
|
print(f"[_parse_branch_json] 开始解析,内容长度={len(content)}")
|
||||||
@@ -567,7 +917,7 @@ class AIService:
|
|||||||
{"role": "user", "content": user_prompt}
|
{"role": "user", "content": user_prompt}
|
||||||
],
|
],
|
||||||
"temperature": 0.85,
|
"temperature": 0.85,
|
||||||
"max_tokens": 6000 # 增加输出长度,确保JSON完整
|
"max_tokens": 8192 # DeepSeek 最大输出限制
|
||||||
}
|
}
|
||||||
|
|
||||||
print(f"[_call_deepseek_long] system_prompt长度={len(system_prompt)}")
|
print(f"[_call_deepseek_long] system_prompt长度={len(system_prompt)}")
|
||||||
|
|||||||
Reference in New Issue
Block a user