feat(home): add AI story expand button and modal

This commit is contained in:
saturn
2026-03-24 23:53:26 +08:00
parent 4e469074e0
commit fd8f5f8635
28 changed files with 615 additions and 3 deletions

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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": "取消"
}
}

View File

@@ -65,6 +65,7 @@
"screenplayConvert": "剧本转换",
"voiceAnalyze": "台词分析",
"analyzeGlobal": "全局分析",
"aiStoryExpand": "AI 故事扩写",
"aiModifyAppearance": "角色描述修改",
"aiModifyLocation": "场景描述修改",
"aiModifyShotPrompt": "镜头提示词修改",
@@ -104,6 +105,8 @@
"scriptToStoryboardStep": "执行分镜生成步骤",
"scriptToStoryboardPersist": "保存分镜结果",
"scriptToStoryboardPersistDone": "分镜与台词结果已保存",
"aiStoryExpandPrepare": "准备故事扩写参数",
"aiStoryExpandDone": "故事扩写已完成",
"insertPanelGenerateText": "生成插入镜头文本",
"insertPanelPersist": "保存插入镜头",
"pollingExternal": "等待外部服务返回",

View File

@@ -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<HTMLTextAreaElement>(null)
const textareaMinHeightRef = useRef<number | null>(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帮我写 + 创建按钮 */}
<div className="flex items-end gap-3 px-5 pb-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-[160px] flex-shrink-0">
@@ -269,6 +293,23 @@ export default function HomePage() {
/>
</div>
</div>
<button
onClick={() => setAiWriteOpen(true)}
disabled={createLoading}
className="glass-btn-base px-4 py-2.5 text-sm flex-shrink-0 border border-[var(--glass-stroke-strong)] hover:border-[var(--glass-tone-info-fg)]/40 transition-all flex items-center gap-1.5"
>
<AppIcon name="sparkles" className="w-4 h-4 text-[#7c3aed]" />
<span
className="font-medium"
style={{
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{t('aiWrite.trigger')}
</span>
</button>
<button
onClick={() => void handleCreate()}
disabled={!inputValue.trim() || createLoading}
@@ -280,6 +321,14 @@ export default function HomePage() {
</div>
</div>
</div>
{/* AI 帮我写模态框 */}
<AiWriteModal
open={aiWriteOpen}
loading={aiWriteLoading}
onClose={() => setAiWriteOpen(false)}
onStart={(prompt) => void handleAiWriteStart(prompt)}
t={(key: string) => t(`aiWrite.${key}`)}
/>
</main>
{/* 最近项目 */}

View File

@@ -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<string, unknown>
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')
})

View File

@@ -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 (
<div
className="fixed inset-0 glass-overlay flex items-center justify-center z-50 backdrop-blur-sm"
onClick={handleClose}
>
<div
className="w-full max-w-lg mx-4 relative"
onClick={(e) => e.stopPropagation()}
>
{/* 模态框容器 */}
<div className="glass-surface-modal rounded-2xl p-6 space-y-5">
{/* 头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(139,92,246,0.15))' }}
>
<AppIcon name="sparkles" className="w-5 h-5 text-[#7c3aed]" />
</div>
<div>
<h3 className="text-lg font-bold text-[var(--glass-text-primary)]">
{t('modalTitle')}
</h3>
<p className="text-xs text-[var(--glass-text-tertiary)]">
{t('modalSubtitle')}
</p>
</div>
</div>
<button
onClick={handleClose}
className="glass-icon-btn-sm"
disabled={loading}
>
<AppIcon name="close" className="w-4 h-4" />
</button>
</div>
{/* 输入区域 */}
<div>
<label className="text-sm font-medium text-[var(--glass-text-secondary)] mb-2 block">
{t('inputLabel')}
</label>
<textarea
value={promptText}
onChange={(e) => setPromptText(e.target.value)}
placeholder={t('placeholder')}
className="glass-textarea-base custom-scrollbar h-36 px-4 py-3 text-sm resize-none placeholder:text-[var(--glass-text-tertiary)]"
disabled={loading}
autoFocus
/>
</div>
{/* 提示文案 */}
<div
className="px-3 py-2 rounded-lg text-xs text-[var(--glass-text-tertiary)] leading-relaxed"
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.06), rgba(139,92,246,0.06))' }}
>
{t('hint')}
</div>
{/* 按钮区域 */}
<div className="flex items-center gap-3">
<button
onClick={handleClose}
disabled={loading}
className="flex-1 py-2.5 text-sm text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] transition-colors rounded-xl"
>
{t('cancel')}
</button>
<button
onClick={handleStart}
disabled={!promptText.trim() || loading}
className="flex-1 py-3 rounded-xl text-white font-semibold text-sm flex items-center justify-center gap-2 transition-all hover:opacity-90 active:scale-[0.98] disabled:opacity-50"
style={{ background: 'linear-gradient(135deg, #3b82f6, #7c3aed)' }}
>
<AppIcon name="sparkles" className="w-4 h-4" />
<span>{loading ? '...' : t('startAiWrite')}</span>
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -31,6 +31,7 @@ const GENERATION_OPERATION_PATTERNS = [
/\/story-to-script(?:-|\/|$)/,
/\/script-to-storyboard(?:-|\/|$)/,
/\/screenplay-conversion(?:\/|$)/,
/\/ai-story-expand(?:\/|$)/,
/\/voice-(?:analyze|design|generate)(?:\/|$)/,
/\/ai-(?:create|modify)-/,
/\/modify-(?:asset|storyboard)-image(?:\/|$)/,

View File

@@ -36,6 +36,7 @@ const BILLABLE_TASK_TYPES = new Set<TaskType>([
TASK_TYPE.SCREENPLAY_CONVERT,
TASK_TYPE.VOICE_ANALYZE,
TASK_TYPE.ANALYZE_GLOBAL,
TASK_TYPE.AI_STORY_EXPAND,
TASK_TYPE.AI_MODIFY_APPEARANCE,
TASK_TYPE.AI_MODIFY_LOCATION,
TASK_TYPE.AI_MODIFY_SHOT_PROMPT,
@@ -277,6 +278,7 @@ export function buildDefaultTaskBillingInfo(taskType: TaskType, payload: AnyPayl
case TASK_TYPE.SCREENPLAY_CONVERT:
case TASK_TYPE.VOICE_ANALYZE:
case TASK_TYPE.ANALYZE_GLOBAL:
case TASK_TYPE.AI_STORY_EXPAND:
case TASK_TYPE.AI_MODIFY_APPEARANCE:
case TASK_TYPE.AI_MODIFY_LOCATION:
case TASK_TYPE.AI_MODIFY_SHOT_PROMPT:

View File

@@ -0,0 +1,41 @@
import { resolveTaskResponse } from '@/lib/task/client'
interface ApiFetchLike {
(input: string, init?: RequestInit): Promise<Response>
}
interface ExpandHomeStoryPayload {
expandedText?: string
}
export interface ExpandHomeStoryParams {
apiFetch: ApiFetchLike
prompt: string
}
export interface ExpandHomeStoryResult {
expandedText: string
}
export async function expandHomeStory({
apiFetch,
prompt,
}: ExpandHomeStoryParams): Promise<ExpandHomeStoryResult> {
const response = await apiFetch('/api/user/ai-story-expand', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
}),
})
const result = await resolveTaskResponse<ExpandHomeStoryPayload>(response)
const expandedText = typeof result.expandedText === 'string' ? result.expandedText.trim() : ''
if (!expandedText) {
throw new Error('AI story expand response missing expandedText')
}
return {
expandedText,
}
}

View File

@@ -22,7 +22,7 @@ export interface HomeWorkspaceLaunchTarget {
pathname: string
query: {
episode: string
autoRun: 'storyToScript'
autoRun?: 'storyToScript'
}
}

View File

@@ -48,6 +48,7 @@ const POLICY_BY_TASK_TYPE: Partial<Record<TaskType, LLMTaskPolicy>> = {
[TASK_TYPE.SCREENPLAY_CONVERT]: LONG_FLOW_HIGH_POLICY,
[TASK_TYPE.VOICE_ANALYZE]: LLM_STANDARD_POLICY,
[TASK_TYPE.ANALYZE_GLOBAL]: LLM_STANDARD_POLICY,
[TASK_TYPE.AI_STORY_EXPAND]: LLM_STANDARD_POLICY,
[TASK_TYPE.AI_MODIFY_APPEARANCE]: LLM_STANDARD_POLICY,
[TASK_TYPE.AI_MODIFY_LOCATION]: LLM_STANDARD_POLICY,
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: LLM_STANDARD_POLICY,

View File

@@ -81,6 +81,10 @@ export const PROMPT_CATALOG: Record<PromptId, PromptCatalogEntry> = {
'clip_content',
],
},
[PROMPT_IDS.NP_AI_STORY_EXPAND]: {
pathStem: 'novel-promotion/ai_story_expand',
variableKeys: ['input'],
},
[PROMPT_IDS.NP_CHARACTER_CREATE]: {
pathStem: 'novel-promotion/character_create',
variableKeys: ['user_input'],

View File

@@ -11,6 +11,7 @@ export const PROMPT_IDS = {
NP_AGENT_STORYBOARD_DETAIL: 'np_agent_storyboard_detail',
NP_AGENT_STORYBOARD_INSERT: 'np_agent_storyboard_insert',
NP_AGENT_STORYBOARD_PLAN: 'np_agent_storyboard_plan',
NP_AI_STORY_EXPAND: 'np_ai_story_expand',
NP_CHARACTER_CREATE: 'np_character_create',
NP_CHARACTER_DESCRIPTION_UPDATE: 'np_character_description_update',
NP_CHARACTER_MODIFY: 'np_character_modify',

View File

@@ -44,6 +44,7 @@ const TASK_INTENT_BY_TYPE: Record<TaskType, TaskIntent> = {
[TASK_TYPE.SCREENPLAY_CONVERT]: 'convert',
[TASK_TYPE.VOICE_ANALYZE]: 'analyze',
[TASK_TYPE.ANALYZE_GLOBAL]: 'analyze',
[TASK_TYPE.AI_STORY_EXPAND]: 'generate',
[TASK_TYPE.AI_MODIFY_APPEARANCE]: 'modify',
[TASK_TYPE.AI_MODIFY_LOCATION]: 'modify',
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'modify',

View File

@@ -23,6 +23,7 @@ const TASK_TYPE_LABELS: Record<string, string> = {
[TASK_TYPE.SCREENPLAY_CONVERT]: 'progress.taskType.screenplayConvert',
[TASK_TYPE.VOICE_ANALYZE]: 'progress.taskType.voiceAnalyze',
[TASK_TYPE.ANALYZE_GLOBAL]: 'progress.taskType.analyzeGlobal',
[TASK_TYPE.AI_STORY_EXPAND]: 'progress.taskType.aiStoryExpand',
[TASK_TYPE.AI_MODIFY_APPEARANCE]: 'progress.taskType.aiModifyAppearance',
[TASK_TYPE.AI_MODIFY_LOCATION]: 'progress.taskType.aiModifyLocation',
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'progress.taskType.aiModifyShotPrompt',
@@ -63,6 +64,8 @@ const STAGE_LABELS: Record<string, string> = {
script_to_storyboard_step: 'progress.stage.scriptToStoryboardStep',
script_to_storyboard_persist: 'progress.stage.scriptToStoryboardPersist',
script_to_storyboard_persist_done: 'progress.stage.scriptToStoryboardPersistDone',
ai_story_expand_prepare: 'progress.stage.aiStoryExpandPrepare',
ai_story_expand_done: 'progress.stage.aiStoryExpandDone',
insert_panel_generate_text: 'progress.stage.insertPanelGenerateText',
insert_panel_persist: 'progress.stage.insertPanelPersist',
polling_external: 'progress.stage.pollingExternal',

View File

@@ -60,6 +60,7 @@ export const TASK_TYPE = {
SCREENPLAY_CONVERT: 'screenplay_convert',
VOICE_ANALYZE: 'voice_analyze',
ANALYZE_GLOBAL: 'analyze_global',
AI_STORY_EXPAND: 'ai_story_expand',
AI_MODIFY_APPEARANCE: 'ai_modify_appearance',
AI_MODIFY_LOCATION: 'ai_modify_location',
AI_MODIFY_SHOT_PROMPT: 'ai_modify_shot_prompt',

View File

@@ -0,0 +1,79 @@
import type { Job } from 'bullmq'
import { executeAiTextStep } from '@/lib/ai-runtime'
import { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
import type { TaskJobData } from '@/lib/task/types'
import { reportTaskProgress } from '@/lib/workers/shared'
import { assertTaskActive } from '@/lib/workers/utils'
import { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'
function readText(value: unknown): string {
return typeof value === 'string' ? value : ''
}
export async function handleAiStoryExpandTask(job: Job<TaskJobData>) {
const payload = (job.data.payload || {}) as Record<string, unknown>
const promptInput = readText(payload.prompt).trim()
const analysisModel = readText(payload.analysisModel).trim()
if (!promptInput) {
throw new Error('prompt is required')
}
if (!analysisModel) {
throw new Error('analysisModel is required')
}
const prompt = buildPrompt({
promptId: PROMPT_IDS.NP_AI_STORY_EXPAND,
locale: job.data.locale,
variables: {
input: promptInput,
},
})
await reportTaskProgress(job, 25, {
stage: 'ai_story_expand_prepare',
stageLabel: '准备故事扩写参数',
displayMode: 'loading',
})
await assertTaskActive(job, 'ai_story_expand_prepare')
const streamContext = createWorkerLLMStreamContext(job, 'ai_story_expand')
const streamCallbacks = createWorkerLLMStreamCallbacks(job, streamContext)
const completion = await withInternalLLMStreamCallbacks(
streamCallbacks,
async () =>
await executeAiTextStep({
userId: job.data.userId,
model: analysisModel,
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
projectId: job.data.projectId || 'home-ai-write',
action: 'ai_story_expand',
meta: {
stepId: 'ai_story_expand',
stepTitle: '故事扩写',
stepIndex: 1,
stepTotal: 1,
},
}),
)
await streamCallbacks.flush()
await assertTaskActive(job, 'ai_story_expand_persist')
const expandedText = completion.text.trim()
if (!expandedText) {
throw new Error('AI story expand response is empty')
}
await reportTaskProgress(job, 96, {
stage: 'ai_story_expand_done',
stageLabel: '故事扩写已完成',
displayMode: 'loading',
})
return {
expandedText,
}
}

View File

@@ -25,6 +25,7 @@ import { handleStoryToScriptTask } from './handlers/story-to-script'
import { handleScriptToStoryboardTask } from './handlers/script-to-storyboard'
import { handleVoiceAnalyzeTask } from './handlers/voice-analyze'
import { handleAssetHubAIDesignTask } from './handlers/asset-hub-ai-design'
import { handleAiStoryExpandTask } from './handlers/ai-story-expand'
import { handleClipsBuildTask } from './handlers/clips-build'
import { handleAnalyzeNovelTask } from './handlers/analyze-novel'
import { handleScreenplayConvertTask } from './handlers/screenplay-convert'
@@ -663,6 +664,8 @@ async function processTextTask(job: Job<TaskJobData>) {
return await handleVoiceAnalyzeTask(job)
case TASK_TYPE.ANALYZE_NOVEL:
return await handleAnalyzeNovelTask(job)
case TASK_TYPE.AI_STORY_EXPAND:
return await handleAiStoryExpandTask(job)
case TASK_TYPE.CLIPS_BUILD:
return await handleClipsBuildTask(job)
case TASK_TYPE.SCREENPLAY_CONVERT:

View File

@@ -163,6 +163,7 @@ const ROUTE_FILES = [
'src/app/api/user/balance/route.ts',
'src/app/api/user/costs/details/route.ts',
'src/app/api/user/costs/route.ts',
'src/app/api/user/ai-story-expand/route.ts',
'src/app/api/user/models/route.ts',
'src/app/api/user/transactions/route.ts',
] as const

View File

@@ -31,6 +31,7 @@ const TASK_TYPE_OWNER_MAP = {
[TASK_TYPE.SCREENPLAY_CONVERT]: 'tests/unit/worker/screenplay-convert.test.ts',
[TASK_TYPE.VOICE_ANALYZE]: 'tests/unit/worker/voice-analyze.test.ts',
[TASK_TYPE.ANALYZE_GLOBAL]: 'tests/unit/worker/analyze-global.test.ts',
[TASK_TYPE.AI_STORY_EXPAND]: 'tests/unit/worker/ai-story-expand.test.ts',
[TASK_TYPE.AI_MODIFY_APPEARANCE]: 'tests/unit/worker/shot-ai-prompt-appearance.test.ts',
[TASK_TYPE.AI_MODIFY_LOCATION]: 'tests/unit/worker/shot-ai-prompt-location.test.ts',
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'tests/unit/worker/shot-ai-prompt-shot.test.ts',

View File

@@ -171,6 +171,13 @@ const ROUTE_CASES: ReadonlyArray<LLMRouteCase> = [
expectedTargetType: 'NovelPromotionLocationDesign',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/user/ai-story-expand/route.ts',
body: { prompt: '宫廷复仇女主回京' },
expectedTaskType: TASK_TYPE.AI_STORY_EXPAND,
expectedTargetType: 'HomeAiStoryExpand',
expectedProjectId: 'home-ai-write',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-modify-appearance/route.ts',
body: {
@@ -336,7 +343,7 @@ describe('api contract - llm observe routes (behavior)', () => {
})
it('keeps expected coverage size', () => {
expect(ROUTE_CASES.length).toBe(22)
expect(ROUTE_CASES.length).toBe(23)
})
for (const routeCase of ROUTE_CASES) {

View File

@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest'
import { expandHomeStory } from '@/lib/home/ai-story-expand'
vi.mock('@/lib/task/client', () => ({
resolveTaskResponse: vi.fn(),
}))
import { resolveTaskResponse } from '@/lib/task/client'
function buildJsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
describe('expandHomeStory', () => {
it('posts the prompt to the user ai-story-expand route and returns expanded text', async () => {
const apiFetch = vi.fn(async () => buildJsonResponse({ async: true, taskId: 'task-1' }))
vi.mocked(resolveTaskResponse).mockResolvedValue({
expandedText: '扩写后的故事正文',
})
const result = await expandHomeStory({
apiFetch,
prompt: '宫廷复仇女主回京',
})
expect(apiFetch).toHaveBeenCalledWith('/api/user/ai-story-expand', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: '宫廷复仇女主回京',
}),
})
expect(result).toEqual({
expandedText: '扩写后的故事正文',
})
})
it('fails explicitly when the route does not return expandedText', async () => {
const apiFetch = vi.fn(async () => buildJsonResponse({ async: true, taskId: 'task-1' }))
vi.mocked(resolveTaskResponse).mockResolvedValue({})
await expect(expandHomeStory({
apiFetch,
prompt: '宫廷复仇女主回京',
})).rejects.toThrow('AI story expand response missing expandedText')
})
})

View File

@@ -59,6 +59,10 @@ vi.mock('@/lib/home/create-project-launch', () => ({
createHomeProjectLaunch: vi.fn(),
}))
vi.mock('@/lib/home/ai-story-expand', () => ({
expandHomeStory: vi.fn(),
}))
describe('resolveTextareaTargetHeight', () => {
it('keeps the home quick-start input at least three rows tall', () => {
expect(resolveTextareaTargetHeight({

View File

@@ -7,6 +7,7 @@ describe('resolveTaskIntent', () => {
expect(resolveTaskIntent(TASK_TYPE.IMAGE_CHARACTER)).toBe('generate')
expect(resolveTaskIntent(TASK_TYPE.IMAGE_LOCATION)).toBe('generate')
expect(resolveTaskIntent(TASK_TYPE.VIDEO_PANEL)).toBe('generate')
expect(resolveTaskIntent(TASK_TYPE.AI_STORY_EXPAND)).toBe('generate')
})
it('maps regenerate and modify task types', () => {

View File

@@ -0,0 +1,84 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const aiRuntimeMock = vi.hoisted(() => ({
executeAiTextStep: vi.fn(async () => ({
text: '扩写后的完整故事内容',
reasoning: '',
})),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
vi.mock('@/lib/ai-runtime', () => aiRuntimeMock)
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_AI_STORY_EXPAND: 'np_ai_story_expand' },
buildPrompt: vi.fn(() => 'story-expand-prompt'),
}))
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({
onStage: vi.fn(),
onChunk: vi.fn(),
onComplete: vi.fn(),
onError: vi.fn(),
flush: vi.fn(async () => undefined),
})),
}))
import { handleAiStoryExpandTask } from '@/lib/workers/handlers/ai-story-expand'
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
return {
data: {
taskId: 'task-ai-story-expand-1',
type: TASK_TYPE.AI_STORY_EXPAND,
locale: 'zh',
projectId: 'home-ai-write',
targetType: 'HomeAiStoryExpand',
targetId: 'user-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker ai-story-expand behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('missing prompt -> explicit error', async () => {
const job = buildJob({ prompt: ' ', analysisModel: 'provider::analysis-model' })
await expect(handleAiStoryExpandTask(job)).rejects.toThrow('prompt is required')
})
it('missing analysis model -> explicit error', async () => {
const job = buildJob({ prompt: '宫廷复仇女主回京' })
await expect(handleAiStoryExpandTask(job)).rejects.toThrow('analysisModel is required')
})
it('success path -> returns expanded text without touching episode persistence', async () => {
const job = buildJob({ prompt: '宫廷复仇女主回京', analysisModel: 'provider::analysis-model' })
const result = await handleAiStoryExpandTask(job)
expect(result).toEqual({
expandedText: '扩写后的完整故事内容',
})
expect(aiRuntimeMock.executeAiTextStep).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
model: 'provider::analysis-model',
projectId: 'home-ai-write',
action: 'ai_story_expand',
}))
})
})