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

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