feat(home): add AI story expand button and modal
This commit is contained in:
39
lib/prompts/novel-promotion/ai_story_expand.en.txt
Normal file
39
lib/prompts/novel-promotion/ai_story_expand.en.txt
Normal 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}
|
||||||
39
lib/prompts/novel-promotion/ai_story_expand.zh.txt
Normal file
39
lib/prompts/novel-promotion/ai_story_expand.zh.txt
Normal 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}
|
||||||
@@ -11,5 +11,15 @@
|
|||||||
"minutesAgo": "{n}m ago",
|
"minutesAgo": "{n}m ago",
|
||||||
"hoursAgo": "{n}h ago",
|
"hoursAgo": "{n}h ago",
|
||||||
"daysAgo": "{n}d 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"screenplayConvert": "Screenplay conversion",
|
"screenplayConvert": "Screenplay conversion",
|
||||||
"voiceAnalyze": "Voice line analysis",
|
"voiceAnalyze": "Voice line analysis",
|
||||||
"analyzeGlobal": "Global analysis",
|
"analyzeGlobal": "Global analysis",
|
||||||
|
"aiStoryExpand": "AI story expansion",
|
||||||
"aiModifyAppearance": "Character description modify",
|
"aiModifyAppearance": "Character description modify",
|
||||||
"aiModifyLocation": "Location description modify",
|
"aiModifyLocation": "Location description modify",
|
||||||
"aiModifyShotPrompt": "Shot prompt modify",
|
"aiModifyShotPrompt": "Shot prompt modify",
|
||||||
@@ -104,6 +105,8 @@
|
|||||||
"scriptToStoryboardStep": "Execute script-to-storyboard step",
|
"scriptToStoryboardStep": "Execute script-to-storyboard step",
|
||||||
"scriptToStoryboardPersist": "Persist script-to-storyboard output",
|
"scriptToStoryboardPersist": "Persist script-to-storyboard output",
|
||||||
"scriptToStoryboardPersistDone": "Storyboard and voice output persisted",
|
"scriptToStoryboardPersistDone": "Storyboard and voice output persisted",
|
||||||
|
"aiStoryExpandPrepare": "Prepare AI story expansion",
|
||||||
|
"aiStoryExpandDone": "AI story expansion completed",
|
||||||
"insertPanelGenerateText": "Generate inserted panel text",
|
"insertPanelGenerateText": "Generate inserted panel text",
|
||||||
"insertPanelPersist": "Persist inserted panel",
|
"insertPanelPersist": "Persist inserted panel",
|
||||||
"pollingExternal": "Waiting for external service",
|
"pollingExternal": "Waiting for external service",
|
||||||
|
|||||||
@@ -11,5 +11,15 @@
|
|||||||
"minutesAgo": "{n}分钟前",
|
"minutesAgo": "{n}分钟前",
|
||||||
"hoursAgo": "{n}小时前",
|
"hoursAgo": "{n}小时前",
|
||||||
"daysAgo": "{n}天前"
|
"daysAgo": "{n}天前"
|
||||||
|
},
|
||||||
|
"aiWrite": {
|
||||||
|
"trigger": "AI 帮我写",
|
||||||
|
"modalTitle": "AI 创作助手",
|
||||||
|
"modalSubtitle": "输入你的创意,让 AI 帮你生成完整故事",
|
||||||
|
"inputLabel": "输入你的创意内容",
|
||||||
|
"placeholder": "输入关键词、故事大纲或简短创意...\n\n例如:\n• 古代宫廷 复仇 悬疑 女主角\n• 第一幕:女主回到京城,暗访旧宅;第二幕:宫廷宴会偶遇仇人之子",
|
||||||
|
"hint": "💡 可以输入关键词、故事大纲、创意描述,AI 会根据你的输入扩展生成完整的故事内容",
|
||||||
|
"startAiWrite": "开始 AI 创作",
|
||||||
|
"cancel": "取消"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"screenplayConvert": "剧本转换",
|
"screenplayConvert": "剧本转换",
|
||||||
"voiceAnalyze": "台词分析",
|
"voiceAnalyze": "台词分析",
|
||||||
"analyzeGlobal": "全局分析",
|
"analyzeGlobal": "全局分析",
|
||||||
|
"aiStoryExpand": "AI 故事扩写",
|
||||||
"aiModifyAppearance": "角色描述修改",
|
"aiModifyAppearance": "角色描述修改",
|
||||||
"aiModifyLocation": "场景描述修改",
|
"aiModifyLocation": "场景描述修改",
|
||||||
"aiModifyShotPrompt": "镜头提示词修改",
|
"aiModifyShotPrompt": "镜头提示词修改",
|
||||||
@@ -104,6 +105,8 @@
|
|||||||
"scriptToStoryboardStep": "执行分镜生成步骤",
|
"scriptToStoryboardStep": "执行分镜生成步骤",
|
||||||
"scriptToStoryboardPersist": "保存分镜结果",
|
"scriptToStoryboardPersist": "保存分镜结果",
|
||||||
"scriptToStoryboardPersistDone": "分镜与台词结果已保存",
|
"scriptToStoryboardPersistDone": "分镜与台词结果已保存",
|
||||||
|
"aiStoryExpandPrepare": "准备故事扩写参数",
|
||||||
|
"aiStoryExpandDone": "故事扩写已完成",
|
||||||
"insertPanelGenerateText": "生成插入镜头文本",
|
"insertPanelGenerateText": "生成插入镜头文本",
|
||||||
"insertPanelPersist": "保存插入镜头",
|
"insertPanelPersist": "保存插入镜头",
|
||||||
"pollingExternal": "等待外部服务返回",
|
"pollingExternal": "等待外部服务返回",
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import { RatioSelector, StyleSelector } from '@/components/selectors/RatioStyleS
|
|||||||
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
|
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
|
||||||
import { Link, useRouter } from '@/i18n/navigation'
|
import { Link, useRouter } from '@/i18n/navigation'
|
||||||
import { apiFetch } from '@/lib/api-fetch'
|
import { apiFetch } from '@/lib/api-fetch'
|
||||||
|
import { expandHomeStory } from '@/lib/home/ai-story-expand'
|
||||||
import { createHomeProjectLaunch } from '@/lib/home/create-project-launch'
|
import { createHomeProjectLaunch } from '@/lib/home/create-project-launch'
|
||||||
import {
|
import {
|
||||||
HOME_QUICK_START_MIN_ROWS,
|
HOME_QUICK_START_MIN_ROWS,
|
||||||
resolveTextareaTargetHeight,
|
resolveTextareaTargetHeight,
|
||||||
} from '@/lib/home/quick-start-textarea'
|
} from '@/lib/home/quick-start-textarea'
|
||||||
|
import AiWriteModal from '@/components/home/AiWriteModal'
|
||||||
|
|
||||||
interface ProjectStats {
|
interface ProjectStats {
|
||||||
episodes: number
|
episodes: number
|
||||||
@@ -50,6 +52,8 @@ export default function HomePage() {
|
|||||||
const [videoRatio, setVideoRatio] = useState('9:16')
|
const [videoRatio, setVideoRatio] = useState('9:16')
|
||||||
const [artStyle, setArtStyle] = useState('american-comic')
|
const [artStyle, setArtStyle] = useState('american-comic')
|
||||||
const [createLoading, setCreateLoading] = useState(false)
|
const [createLoading, setCreateLoading] = useState(false)
|
||||||
|
const [aiWriteOpen, setAiWriteOpen] = useState(false)
|
||||||
|
const [aiWriteLoading, setAiWriteLoading] = useState(false)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const textareaMinHeightRef = useRef<number | null>(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(
|
const ratioOptions = useMemo(
|
||||||
() => VIDEO_RATIOS.map((r) => ({ ...r, recommended: r.value === '9:16' })),
|
() => 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"
|
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-end gap-3 px-5 pb-4">
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<div className="w-[160px] flex-shrink-0">
|
<div className="w-[160px] flex-shrink-0">
|
||||||
@@ -269,6 +293,23 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
onClick={() => void handleCreate()}
|
onClick={() => void handleCreate()}
|
||||||
disabled={!inputValue.trim() || createLoading}
|
disabled={!inputValue.trim() || createLoading}
|
||||||
@@ -280,6 +321,14 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* AI 帮我写模态框 */}
|
||||||
|
<AiWriteModal
|
||||||
|
open={aiWriteOpen}
|
||||||
|
loading={aiWriteLoading}
|
||||||
|
onClose={() => setAiWriteOpen(false)}
|
||||||
|
onStart={(prompt) => void handleAiWriteStart(prompt)}
|
||||||
|
t={(key: string) => t(`aiWrite.${key}`)}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* 最近项目 */}
|
{/* 最近项目 */}
|
||||||
|
|||||||
48
src/app/api/user/ai-story-expand/route.ts
Normal file
48
src/app/api/user/ai-story-expand/route.ts
Normal 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')
|
||||||
|
})
|
||||||
126
src/components/home/AiWriteModal.tsx
Normal file
126
src/components/home/AiWriteModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ const GENERATION_OPERATION_PATTERNS = [
|
|||||||
/\/story-to-script(?:-|\/|$)/,
|
/\/story-to-script(?:-|\/|$)/,
|
||||||
/\/script-to-storyboard(?:-|\/|$)/,
|
/\/script-to-storyboard(?:-|\/|$)/,
|
||||||
/\/screenplay-conversion(?:\/|$)/,
|
/\/screenplay-conversion(?:\/|$)/,
|
||||||
|
/\/ai-story-expand(?:\/|$)/,
|
||||||
/\/voice-(?:analyze|design|generate)(?:\/|$)/,
|
/\/voice-(?:analyze|design|generate)(?:\/|$)/,
|
||||||
/\/ai-(?:create|modify)-/,
|
/\/ai-(?:create|modify)-/,
|
||||||
/\/modify-(?:asset|storyboard)-image(?:\/|$)/,
|
/\/modify-(?:asset|storyboard)-image(?:\/|$)/,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const BILLABLE_TASK_TYPES = new Set<TaskType>([
|
|||||||
TASK_TYPE.SCREENPLAY_CONVERT,
|
TASK_TYPE.SCREENPLAY_CONVERT,
|
||||||
TASK_TYPE.VOICE_ANALYZE,
|
TASK_TYPE.VOICE_ANALYZE,
|
||||||
TASK_TYPE.ANALYZE_GLOBAL,
|
TASK_TYPE.ANALYZE_GLOBAL,
|
||||||
|
TASK_TYPE.AI_STORY_EXPAND,
|
||||||
TASK_TYPE.AI_MODIFY_APPEARANCE,
|
TASK_TYPE.AI_MODIFY_APPEARANCE,
|
||||||
TASK_TYPE.AI_MODIFY_LOCATION,
|
TASK_TYPE.AI_MODIFY_LOCATION,
|
||||||
TASK_TYPE.AI_MODIFY_SHOT_PROMPT,
|
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.SCREENPLAY_CONVERT:
|
||||||
case TASK_TYPE.VOICE_ANALYZE:
|
case TASK_TYPE.VOICE_ANALYZE:
|
||||||
case TASK_TYPE.ANALYZE_GLOBAL:
|
case TASK_TYPE.ANALYZE_GLOBAL:
|
||||||
|
case TASK_TYPE.AI_STORY_EXPAND:
|
||||||
case TASK_TYPE.AI_MODIFY_APPEARANCE:
|
case TASK_TYPE.AI_MODIFY_APPEARANCE:
|
||||||
case TASK_TYPE.AI_MODIFY_LOCATION:
|
case TASK_TYPE.AI_MODIFY_LOCATION:
|
||||||
case TASK_TYPE.AI_MODIFY_SHOT_PROMPT:
|
case TASK_TYPE.AI_MODIFY_SHOT_PROMPT:
|
||||||
|
|||||||
41
src/lib/home/ai-story-expand.ts
Normal file
41
src/lib/home/ai-story-expand.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export interface HomeWorkspaceLaunchTarget {
|
|||||||
pathname: string
|
pathname: string
|
||||||
query: {
|
query: {
|
||||||
episode: string
|
episode: string
|
||||||
autoRun: 'storyToScript'
|
autoRun?: 'storyToScript'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const POLICY_BY_TASK_TYPE: Partial<Record<TaskType, LLMTaskPolicy>> = {
|
|||||||
[TASK_TYPE.SCREENPLAY_CONVERT]: LONG_FLOW_HIGH_POLICY,
|
[TASK_TYPE.SCREENPLAY_CONVERT]: LONG_FLOW_HIGH_POLICY,
|
||||||
[TASK_TYPE.VOICE_ANALYZE]: LLM_STANDARD_POLICY,
|
[TASK_TYPE.VOICE_ANALYZE]: LLM_STANDARD_POLICY,
|
||||||
[TASK_TYPE.ANALYZE_GLOBAL]: 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_APPEARANCE]: LLM_STANDARD_POLICY,
|
||||||
[TASK_TYPE.AI_MODIFY_LOCATION]: LLM_STANDARD_POLICY,
|
[TASK_TYPE.AI_MODIFY_LOCATION]: LLM_STANDARD_POLICY,
|
||||||
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: LLM_STANDARD_POLICY,
|
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: LLM_STANDARD_POLICY,
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export const PROMPT_CATALOG: Record<PromptId, PromptCatalogEntry> = {
|
|||||||
'clip_content',
|
'clip_content',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
[PROMPT_IDS.NP_AI_STORY_EXPAND]: {
|
||||||
|
pathStem: 'novel-promotion/ai_story_expand',
|
||||||
|
variableKeys: ['input'],
|
||||||
|
},
|
||||||
[PROMPT_IDS.NP_CHARACTER_CREATE]: {
|
[PROMPT_IDS.NP_CHARACTER_CREATE]: {
|
||||||
pathStem: 'novel-promotion/character_create',
|
pathStem: 'novel-promotion/character_create',
|
||||||
variableKeys: ['user_input'],
|
variableKeys: ['user_input'],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const PROMPT_IDS = {
|
|||||||
NP_AGENT_STORYBOARD_DETAIL: 'np_agent_storyboard_detail',
|
NP_AGENT_STORYBOARD_DETAIL: 'np_agent_storyboard_detail',
|
||||||
NP_AGENT_STORYBOARD_INSERT: 'np_agent_storyboard_insert',
|
NP_AGENT_STORYBOARD_INSERT: 'np_agent_storyboard_insert',
|
||||||
NP_AGENT_STORYBOARD_PLAN: 'np_agent_storyboard_plan',
|
NP_AGENT_STORYBOARD_PLAN: 'np_agent_storyboard_plan',
|
||||||
|
NP_AI_STORY_EXPAND: 'np_ai_story_expand',
|
||||||
NP_CHARACTER_CREATE: 'np_character_create',
|
NP_CHARACTER_CREATE: 'np_character_create',
|
||||||
NP_CHARACTER_DESCRIPTION_UPDATE: 'np_character_description_update',
|
NP_CHARACTER_DESCRIPTION_UPDATE: 'np_character_description_update',
|
||||||
NP_CHARACTER_MODIFY: 'np_character_modify',
|
NP_CHARACTER_MODIFY: 'np_character_modify',
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const TASK_INTENT_BY_TYPE: Record<TaskType, TaskIntent> = {
|
|||||||
[TASK_TYPE.SCREENPLAY_CONVERT]: 'convert',
|
[TASK_TYPE.SCREENPLAY_CONVERT]: 'convert',
|
||||||
[TASK_TYPE.VOICE_ANALYZE]: 'analyze',
|
[TASK_TYPE.VOICE_ANALYZE]: 'analyze',
|
||||||
[TASK_TYPE.ANALYZE_GLOBAL]: 'analyze',
|
[TASK_TYPE.ANALYZE_GLOBAL]: 'analyze',
|
||||||
|
[TASK_TYPE.AI_STORY_EXPAND]: 'generate',
|
||||||
[TASK_TYPE.AI_MODIFY_APPEARANCE]: 'modify',
|
[TASK_TYPE.AI_MODIFY_APPEARANCE]: 'modify',
|
||||||
[TASK_TYPE.AI_MODIFY_LOCATION]: 'modify',
|
[TASK_TYPE.AI_MODIFY_LOCATION]: 'modify',
|
||||||
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'modify',
|
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'modify',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const TASK_TYPE_LABELS: Record<string, string> = {
|
|||||||
[TASK_TYPE.SCREENPLAY_CONVERT]: 'progress.taskType.screenplayConvert',
|
[TASK_TYPE.SCREENPLAY_CONVERT]: 'progress.taskType.screenplayConvert',
|
||||||
[TASK_TYPE.VOICE_ANALYZE]: 'progress.taskType.voiceAnalyze',
|
[TASK_TYPE.VOICE_ANALYZE]: 'progress.taskType.voiceAnalyze',
|
||||||
[TASK_TYPE.ANALYZE_GLOBAL]: 'progress.taskType.analyzeGlobal',
|
[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_APPEARANCE]: 'progress.taskType.aiModifyAppearance',
|
||||||
[TASK_TYPE.AI_MODIFY_LOCATION]: 'progress.taskType.aiModifyLocation',
|
[TASK_TYPE.AI_MODIFY_LOCATION]: 'progress.taskType.aiModifyLocation',
|
||||||
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'progress.taskType.aiModifyShotPrompt',
|
[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_step: 'progress.stage.scriptToStoryboardStep',
|
||||||
script_to_storyboard_persist: 'progress.stage.scriptToStoryboardPersist',
|
script_to_storyboard_persist: 'progress.stage.scriptToStoryboardPersist',
|
||||||
script_to_storyboard_persist_done: 'progress.stage.scriptToStoryboardPersistDone',
|
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_generate_text: 'progress.stage.insertPanelGenerateText',
|
||||||
insert_panel_persist: 'progress.stage.insertPanelPersist',
|
insert_panel_persist: 'progress.stage.insertPanelPersist',
|
||||||
polling_external: 'progress.stage.pollingExternal',
|
polling_external: 'progress.stage.pollingExternal',
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const TASK_TYPE = {
|
|||||||
SCREENPLAY_CONVERT: 'screenplay_convert',
|
SCREENPLAY_CONVERT: 'screenplay_convert',
|
||||||
VOICE_ANALYZE: 'voice_analyze',
|
VOICE_ANALYZE: 'voice_analyze',
|
||||||
ANALYZE_GLOBAL: 'analyze_global',
|
ANALYZE_GLOBAL: 'analyze_global',
|
||||||
|
AI_STORY_EXPAND: 'ai_story_expand',
|
||||||
AI_MODIFY_APPEARANCE: 'ai_modify_appearance',
|
AI_MODIFY_APPEARANCE: 'ai_modify_appearance',
|
||||||
AI_MODIFY_LOCATION: 'ai_modify_location',
|
AI_MODIFY_LOCATION: 'ai_modify_location',
|
||||||
AI_MODIFY_SHOT_PROMPT: 'ai_modify_shot_prompt',
|
AI_MODIFY_SHOT_PROMPT: 'ai_modify_shot_prompt',
|
||||||
|
|||||||
79
src/lib/workers/handlers/ai-story-expand.ts
Normal file
79
src/lib/workers/handlers/ai-story-expand.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { handleStoryToScriptTask } from './handlers/story-to-script'
|
|||||||
import { handleScriptToStoryboardTask } from './handlers/script-to-storyboard'
|
import { handleScriptToStoryboardTask } from './handlers/script-to-storyboard'
|
||||||
import { handleVoiceAnalyzeTask } from './handlers/voice-analyze'
|
import { handleVoiceAnalyzeTask } from './handlers/voice-analyze'
|
||||||
import { handleAssetHubAIDesignTask } from './handlers/asset-hub-ai-design'
|
import { handleAssetHubAIDesignTask } from './handlers/asset-hub-ai-design'
|
||||||
|
import { handleAiStoryExpandTask } from './handlers/ai-story-expand'
|
||||||
import { handleClipsBuildTask } from './handlers/clips-build'
|
import { handleClipsBuildTask } from './handlers/clips-build'
|
||||||
import { handleAnalyzeNovelTask } from './handlers/analyze-novel'
|
import { handleAnalyzeNovelTask } from './handlers/analyze-novel'
|
||||||
import { handleScreenplayConvertTask } from './handlers/screenplay-convert'
|
import { handleScreenplayConvertTask } from './handlers/screenplay-convert'
|
||||||
@@ -663,6 +664,8 @@ async function processTextTask(job: Job<TaskJobData>) {
|
|||||||
return await handleVoiceAnalyzeTask(job)
|
return await handleVoiceAnalyzeTask(job)
|
||||||
case TASK_TYPE.ANALYZE_NOVEL:
|
case TASK_TYPE.ANALYZE_NOVEL:
|
||||||
return await handleAnalyzeNovelTask(job)
|
return await handleAnalyzeNovelTask(job)
|
||||||
|
case TASK_TYPE.AI_STORY_EXPAND:
|
||||||
|
return await handleAiStoryExpandTask(job)
|
||||||
case TASK_TYPE.CLIPS_BUILD:
|
case TASK_TYPE.CLIPS_BUILD:
|
||||||
return await handleClipsBuildTask(job)
|
return await handleClipsBuildTask(job)
|
||||||
case TASK_TYPE.SCREENPLAY_CONVERT:
|
case TASK_TYPE.SCREENPLAY_CONVERT:
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ const ROUTE_FILES = [
|
|||||||
'src/app/api/user/balance/route.ts',
|
'src/app/api/user/balance/route.ts',
|
||||||
'src/app/api/user/costs/details/route.ts',
|
'src/app/api/user/costs/details/route.ts',
|
||||||
'src/app/api/user/costs/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/models/route.ts',
|
||||||
'src/app/api/user/transactions/route.ts',
|
'src/app/api/user/transactions/route.ts',
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const TASK_TYPE_OWNER_MAP = {
|
|||||||
[TASK_TYPE.SCREENPLAY_CONVERT]: 'tests/unit/worker/screenplay-convert.test.ts',
|
[TASK_TYPE.SCREENPLAY_CONVERT]: 'tests/unit/worker/screenplay-convert.test.ts',
|
||||||
[TASK_TYPE.VOICE_ANALYZE]: 'tests/unit/worker/voice-analyze.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.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_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_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',
|
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'tests/unit/worker/shot-ai-prompt-shot.test.ts',
|
||||||
|
|||||||
@@ -171,6 +171,13 @@ const ROUTE_CASES: ReadonlyArray<LLMRouteCase> = [
|
|||||||
expectedTargetType: 'NovelPromotionLocationDesign',
|
expectedTargetType: 'NovelPromotionLocationDesign',
|
||||||
expectedProjectId: 'project-1',
|
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',
|
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-modify-appearance/route.ts',
|
||||||
body: {
|
body: {
|
||||||
@@ -336,7 +343,7 @@ describe('api contract - llm observe routes (behavior)', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('keeps expected coverage size', () => {
|
it('keeps expected coverage size', () => {
|
||||||
expect(ROUTE_CASES.length).toBe(22)
|
expect(ROUTE_CASES.length).toBe(23)
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const routeCase of ROUTE_CASES) {
|
for (const routeCase of ROUTE_CASES) {
|
||||||
|
|||||||
50
tests/unit/home/ai-story-expand.test.ts
Normal file
50
tests/unit/home/ai-story-expand.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -59,6 +59,10 @@ vi.mock('@/lib/home/create-project-launch', () => ({
|
|||||||
createHomeProjectLaunch: vi.fn(),
|
createHomeProjectLaunch: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/home/ai-story-expand', () => ({
|
||||||
|
expandHomeStory: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
describe('resolveTextareaTargetHeight', () => {
|
describe('resolveTextareaTargetHeight', () => {
|
||||||
it('keeps the home quick-start input at least three rows tall', () => {
|
it('keeps the home quick-start input at least three rows tall', () => {
|
||||||
expect(resolveTextareaTargetHeight({
|
expect(resolveTextareaTargetHeight({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ describe('resolveTaskIntent', () => {
|
|||||||
expect(resolveTaskIntent(TASK_TYPE.IMAGE_CHARACTER)).toBe('generate')
|
expect(resolveTaskIntent(TASK_TYPE.IMAGE_CHARACTER)).toBe('generate')
|
||||||
expect(resolveTaskIntent(TASK_TYPE.IMAGE_LOCATION)).toBe('generate')
|
expect(resolveTaskIntent(TASK_TYPE.IMAGE_LOCATION)).toBe('generate')
|
||||||
expect(resolveTaskIntent(TASK_TYPE.VIDEO_PANEL)).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', () => {
|
it('maps regenerate and modify task types', () => {
|
||||||
|
|||||||
84
tests/unit/worker/ai-story-expand.test.ts
Normal file
84
tests/unit/worker/ai-story-expand.test.ts
Normal 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',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user