diff --git a/lib/prompts/novel-promotion/ai_story_expand.en.txt b/lib/prompts/novel-promotion/ai_story_expand.en.txt new file mode 100644 index 0000000..80398d5 --- /dev/null +++ b/lib/prompts/novel-promotion/ai_story_expand.en.txt @@ -0,0 +1,39 @@ +You are a professional screenwriting master, skilled at expanding short ideas, keywords, or outlines into complete story/script content. + +## Your Task + +Based on the user's creative input (which may be keywords, short descriptions, story outlines, or content to rewrite), create a complete, high-quality story suitable for short drama production. + +## Writing Requirements + +### Content Quality +1. The story must have a clear beginning, development, and ending (or climactic suspense) +2. Characters should have distinct personality traits and motivations +3. Scene descriptions should be specific and visually compelling, suitable for storyboard conversion +4. Dialogue should be natural, tension-filled, and drive the plot forward +5. Pacing should be tight, avoiding lengthy background exposition + +### Format Requirements +1. Use third-person perspective narration +2. Separate scene transitions with blank lines +3. Briefly introduce characters when they first appear +4. Mark dialogue with quotation marks and identify the speaker +5. Include action and expression descriptions at key visual moments + +### Length Control (⚠️ Most Important) +- Goal: generate story content suitable for a 1-2 minute short film +- Total length must be strictly between 300-800 words, never exceeding 800 words +- Short keyword input: generate 400-600 word stories +- Outline input: keep overall length to 500-800 words, expand each point concisely +- Prefer concise over verbose; every sentence should be visual and dramatic +- Characters and scenes should follow naturally from the user's input; do not invent characters or scenes the user did not mention + +### Prohibited +- Do not output any non-story content (such as "Here is the generated story" etc.) +- Do not output titles or chapter numbers +- Do not use markdown formatting +- Output story text directly + +## User Input + +{input} diff --git a/lib/prompts/novel-promotion/ai_story_expand.zh.txt b/lib/prompts/novel-promotion/ai_story_expand.zh.txt new file mode 100644 index 0000000..8c9ac9b --- /dev/null +++ b/lib/prompts/novel-promotion/ai_story_expand.zh.txt @@ -0,0 +1,39 @@ +你是一个专业的影视剧本创作大师,擅长将简短的创意、关键词或大纲扩展为完整的故事/剧本内容。 + +## 你的任务 + +根据用户提供的创意输入(可能是关键词、简短描述、故事大纲或需要改写的内容),创作一段完整的、高质量的故事内容,用于后续的影视短剧制作。 + +## 创作要求 + +### 内容质量 +1. 故事必须有清晰的开头、发展和结尾(或高潮悬念) +2. 角色要有鲜明的性格特征和行为动机 +3. 场景描写要具体、画面感强,适合转化为分镜画面 +4. 对话要自然、有张力,推动剧情发展 +5. 情节节奏紧凑,避免冗长的背景铺陈 + +### 格式要求 +1. 使用第三人称视角叙述 +2. 场景转换时用空行分隔 +3. 角色名称在首次出现时需要简短介绍 +4. 对话使用引号标注,并注明说话者 +5. 在关键画面处加入动作和表情描写 + +### 篇幅控制(⚠️ 最重要) +- 目标:生成适合 1-2 分钟影视短片的故事内容 +- 总篇幅严格控制在 300-800 字之间,绝不超过 800 字 +- 简短关键词输入:生成 400-600 字的故事 +- 大纲输入:整体控制在 500-800 字,每个要点精炼展开 +- 宁可精炼也不要冗长,每一句话都要有画面感和戏剧张力 +- 角色和场景数量根据用户输入自然决定,不要额外发明用户未提及的角色或场景 + +### 禁止事项 +- 不要输出任何非故事内容(如"以下是生成的故事"等说明文字) +- 不要输出标题、章节号 +- 不要使用 markdown 格式 +- 直接输出故事正文内容 + +## 用户输入 + +{input} diff --git a/messages/en/home.json b/messages/en/home.json index bc48079..6bda9ce 100644 --- a/messages/en/home.json +++ b/messages/en/home.json @@ -11,5 +11,15 @@ "minutesAgo": "{n}m ago", "hoursAgo": "{n}h ago", "daysAgo": "{n}d ago" + }, + "aiWrite": { + "trigger": "AI Write", + "modalTitle": "AI Writing Assistant", + "modalSubtitle": "Enter your idea and let AI generate a complete story", + "inputLabel": "Enter your creative idea", + "placeholder": "Enter keywords, story outline, or a short idea...\n\ne.g.\n• ancient palace, revenge, mystery, female lead\n• Act 1: The heroine returns to the capital; Act 2: A chance encounter at the royal banquet", + "hint": "💡 You can enter keywords, story outlines, or creative descriptions. AI will expand your input into a complete story", + "startAiWrite": "Start AI Writing", + "cancel": "Cancel" } } diff --git a/messages/en/progress.json b/messages/en/progress.json index 0b2e2ed..331ca65 100644 --- a/messages/en/progress.json +++ b/messages/en/progress.json @@ -65,6 +65,7 @@ "screenplayConvert": "Screenplay conversion", "voiceAnalyze": "Voice line analysis", "analyzeGlobal": "Global analysis", + "aiStoryExpand": "AI story expansion", "aiModifyAppearance": "Character description modify", "aiModifyLocation": "Location description modify", "aiModifyShotPrompt": "Shot prompt modify", @@ -104,6 +105,8 @@ "scriptToStoryboardStep": "Execute script-to-storyboard step", "scriptToStoryboardPersist": "Persist script-to-storyboard output", "scriptToStoryboardPersistDone": "Storyboard and voice output persisted", + "aiStoryExpandPrepare": "Prepare AI story expansion", + "aiStoryExpandDone": "AI story expansion completed", "insertPanelGenerateText": "Generate inserted panel text", "insertPanelPersist": "Persist inserted panel", "pollingExternal": "Waiting for external service", diff --git a/messages/zh/home.json b/messages/zh/home.json index 71dbab1..c938607 100644 --- a/messages/zh/home.json +++ b/messages/zh/home.json @@ -11,5 +11,15 @@ "minutesAgo": "{n}分钟前", "hoursAgo": "{n}小时前", "daysAgo": "{n}天前" + }, + "aiWrite": { + "trigger": "AI 帮我写", + "modalTitle": "AI 创作助手", + "modalSubtitle": "输入你的创意,让 AI 帮你生成完整故事", + "inputLabel": "输入你的创意内容", + "placeholder": "输入关键词、故事大纲或简短创意...\n\n例如:\n• 古代宫廷 复仇 悬疑 女主角\n• 第一幕:女主回到京城,暗访旧宅;第二幕:宫廷宴会偶遇仇人之子", + "hint": "💡 可以输入关键词、故事大纲、创意描述,AI 会根据你的输入扩展生成完整的故事内容", + "startAiWrite": "开始 AI 创作", + "cancel": "取消" } } diff --git a/messages/zh/progress.json b/messages/zh/progress.json index 5010522..a52a52a 100644 --- a/messages/zh/progress.json +++ b/messages/zh/progress.json @@ -65,6 +65,7 @@ "screenplayConvert": "剧本转换", "voiceAnalyze": "台词分析", "analyzeGlobal": "全局分析", + "aiStoryExpand": "AI 故事扩写", "aiModifyAppearance": "角色描述修改", "aiModifyLocation": "场景描述修改", "aiModifyShotPrompt": "镜头提示词修改", @@ -104,6 +105,8 @@ "scriptToStoryboardStep": "执行分镜生成步骤", "scriptToStoryboardPersist": "保存分镜结果", "scriptToStoryboardPersistDone": "分镜与台词结果已保存", + "aiStoryExpandPrepare": "准备故事扩写参数", + "aiStoryExpandDone": "故事扩写已完成", "insertPanelGenerateText": "生成插入镜头文本", "insertPanelPersist": "保存插入镜头", "pollingExternal": "等待外部服务返回", diff --git a/src/app/[locale]/home/page.tsx b/src/app/[locale]/home/page.tsx index b37897a..4ee90cc 100644 --- a/src/app/[locale]/home/page.tsx +++ b/src/app/[locale]/home/page.tsx @@ -13,11 +13,13 @@ import { RatioSelector, StyleSelector } from '@/components/selectors/RatioStyleS import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants' import { Link, useRouter } from '@/i18n/navigation' import { apiFetch } from '@/lib/api-fetch' +import { expandHomeStory } from '@/lib/home/ai-story-expand' import { createHomeProjectLaunch } from '@/lib/home/create-project-launch' import { HOME_QUICK_START_MIN_ROWS, resolveTextareaTargetHeight, } from '@/lib/home/quick-start-textarea' +import AiWriteModal from '@/components/home/AiWriteModal' interface ProjectStats { episodes: number @@ -50,6 +52,8 @@ export default function HomePage() { const [videoRatio, setVideoRatio] = useState('9:16') const [artStyle, setArtStyle] = useState('american-comic') const [createLoading, setCreateLoading] = useState(false) + const [aiWriteOpen, setAiWriteOpen] = useState(false) + const [aiWriteLoading, setAiWriteLoading] = useState(false) const textareaRef = useRef(null) const textareaMinHeightRef = useRef(null) @@ -148,6 +152,26 @@ export default function HomePage() { } } + // AI 帮我写 — 直接生成文本并回填首页输入框 + const handleAiWriteStart = async (prompt: string) => { + if (aiWriteLoading) return + setAiWriteLoading(true) + try { + const result = await expandHomeStory({ + apiFetch, + prompt, + }) + + setInputValue(result.expandedText) + setAiWriteOpen(false) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed' + window.alert(message) + } finally { + setAiWriteLoading(false) + } + } + // 比例选项(带推荐标签) const ratioOptions = useMemo( () => VIDEO_RATIOS.map((r) => ({ ...r, recommended: r.value === '9:16' })), @@ -251,7 +275,7 @@ export default function HomePage() { className="w-full bg-transparent border-none outline-none text-[var(--glass-text-primary)] placeholder:text-[var(--glass-text-tertiary)] text-base resize-none p-5 pb-3 custom-scrollbar" /> - {/* 底部工具栏:比例 + 风格 + 创建按钮 */} + {/* 底部工具栏:比例 + 风格 + AI帮我写 + 创建按钮 */}
@@ -269,6 +293,23 @@ export default function HomePage() { />
+
+ {/* AI 帮我写模态框 */} + setAiWriteOpen(false)} + onStart={(prompt) => void handleAiWriteStart(prompt)} + t={(key: string) => t(`aiWrite.${key}`)} + /> {/* 最近项目 */} diff --git a/src/app/api/user/ai-story-expand/route.ts b/src/app/api/user/ai-story-expand/route.ts new file mode 100644 index 0000000..d8a2279 --- /dev/null +++ b/src/app/api/user/ai-story-expand/route.ts @@ -0,0 +1,48 @@ +import { createHash } from 'crypto' +import { NextRequest } from 'next/server' +import { requireUserAuth, isErrorResponse } from '@/lib/api-auth' +import { apiHandler, ApiError } from '@/lib/api-errors' +import { getUserModelConfig } from '@/lib/config-service' +import { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task' +import { TASK_TYPE } from '@/lib/task/types' + +export const POST = apiHandler(async (request: NextRequest) => { + const authResult = await requireUserAuth() + if (isErrorResponse(authResult)) return authResult + const { session } = authResult + + const body = (await request.json().catch(() => ({}))) as Record + const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '' + if (!prompt) { + throw new ApiError('INVALID_PARAMS') + } + + const userConfig = await getUserModelConfig(session.user.id) + if (!userConfig.analysisModel) { + throw new ApiError('MISSING_CONFIG') + } + + const dedupeDigest = createHash('sha1') + .update(`${session.user.id}:home-story-expand:${prompt}`) + .digest('hex') + .slice(0, 16) + + const asyncTaskResponse = await maybeSubmitLLMTask({ + request, + userId: session.user.id, + projectId: 'home-ai-write', + type: TASK_TYPE.AI_STORY_EXPAND, + targetType: 'HomeAiStoryExpand', + targetId: session.user.id, + routePath: '/api/user/ai-story-expand', + body: { + prompt, + analysisModel: userConfig.analysisModel, + }, + dedupeKey: `home_ai_story_expand:${dedupeDigest}`, + priority: 1, + }) + if (asyncTaskResponse) return asyncTaskResponse + + throw new ApiError('INVALID_PARAMS') +}) diff --git a/src/components/home/AiWriteModal.tsx b/src/components/home/AiWriteModal.tsx new file mode 100644 index 0000000..f5e7fb8 --- /dev/null +++ b/src/components/home/AiWriteModal.tsx @@ -0,0 +1,126 @@ +'use client' + +/** + * AI 帮我写 — 首页轻量模态框 + * + * 用户输入创意/关键词/大纲,直接生成结果并回填首页主输入框 + */ + +import { useState, useCallback } from 'react' +import { AppIcon } from '@/components/ui/icons' + +interface AiWriteModalProps { + open: boolean + loading: boolean + onClose: () => void + onStart: (prompt: string) => void + t: (key: string) => string +} + +export default function AiWriteModal({ + open, + loading, + onClose, + onStart, + t, +}: AiWriteModalProps) { + const [promptText, setPromptText] = useState('') + + const handleClose = useCallback(() => { + if (loading) return + setPromptText('') + onClose() + }, [loading, onClose]) + + const handleStart = useCallback(() => { + if (!promptText.trim() || loading) return + onStart(promptText.trim()) + }, [promptText, loading, onStart]) + + if (!open) return null + + return ( +
+
e.stopPropagation()} + > + {/* 模态框容器 */} +
+ {/* 头部 */} +
+
+
+ +
+
+

+ {t('modalTitle')} +

+

+ {t('modalSubtitle')} +

+
+
+ +
+ + {/* 输入区域 */} +
+ +