feat(mini-program): ScriptView 多阶段状态机改造

This commit is contained in:
2026-07-19 12:39:50 +08:00
parent b3a2f3a3d3
commit 34f443939e
+308 -42
View File
@@ -86,6 +86,56 @@
</view>
</view>
<view v-else-if="viewState === 'clarifying'" class="clarifying-view">
<scroll-view class="clarifying-scroll" scroll-y>
<view class="clarifying-content">
<view class="clarifying-intro">
<text class="intro-title">在开始创作前我想再了解你一点</text>
</view>
<ClarificationCard
v-if="clarificationCard"
:card="clarificationCard"
@submit="submitClarification"
/>
</view>
</scroll-view>
</view>
<view v-else-if="viewState === 'outlining'" class="outlining-view">
<scroll-view class="outlining-scroll" scroll-y>
<view class="outlining-content">
<view v-if="novelOutline" class="outline-card">
<view v-if="novelOutline.title" class="outline-title">
<text>{{ novelOutline.title }}</text>
</view>
<view v-if="novelOutline.logline" class="outline-logline">
<text>{{ novelOutline.logline }}</text>
</view>
<view v-if="Array.isArray(novelOutline.beats)" class="outline-beats">
<view v-for="(beat, idx) in novelOutline.beats" :key="idx" class="beat-item">
<text class="beat-order">{{ beat.order || idx + 1 }}</text>
<view class="beat-content">
<text class="beat-title">{{ beat.title || '' }}</text>
<text v-if="beat.summary" class="beat-summary">{{ beat.summary }}</text>
</view>
</view>
</view>
<view v-if="novelOutline.ending" class="outline-ending">
<text class="ending-label">结局</text>
<text class="ending-text">{{ novelOutline.ending }}</text>
</view>
</view>
<view class="outline-actions">
<input v-model="outlineFeedback" placeholder="如需修改请输入意见" class="outline-feedback" />
<view class="outline-buttons">
<view class="btn-secondary" @click="modifyOutline(outlineFeedback)">修改大纲</view>
<view class="btn-primary" @click="confirmOutline">确认大纲</view>
</view>
</view>
</view>
</scroll-view>
</view>
<view v-else-if="viewState === 'generating'" class="generation-view">
<scroll-view
class="generation-scroll"
@@ -267,9 +317,12 @@ import { useTypewriterStream } from '../../composables/useTypewriterStream.js'
import { transcribeAudio } from '../../services/asr.js'
import { streamAiScene } from '../../services/aiRuntime.js'
import MessageCard from '../../components/MessageCard.vue'
import ClarificationCard from '../../components/ClarificationCard.vue'
import {
startNovelStream,
followupStream
} from '../../services/shortNovel.js'
import {
createScriptWithDialogue,
streamScriptChat,
listMessagesByConversation,
listMessageVersions,
switchVersion,
@@ -326,6 +379,14 @@ const viewMode = ref('chat')
const scriptId = ref('')
const conversationId = ref('')
const currentVersionMessageId = ref('')
// 短篇小说 SSE 流式生成新增字段
const novelSessionId = ref('')
const novelFullText = ref('')
const novelOutline = ref(null)
const clarificationCard = ref(null)
const currentStreamTask = ref(null)
const answeringClarification = ref(false)
const outlineFeedback = ref('')
const messages = ref([])
const versions = ref([])
const chatInput = ref('')
@@ -1576,6 +1637,11 @@ const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme }
generationStartedAt.value = Date.now()
generating.value = true
streamContent.value = ''
novelFullText.value = ''
novelOutline.value = null
clarificationCard.value = null
novelSessionId.value = ''
outlineFeedback.value = ''
generationScrollTarget.value = ''
generationScrollTop.value = 0
generationScrollAnchor.value = 'generation-stream-anchor-a'
@@ -1587,45 +1653,17 @@ const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme }
keepGenerationAtBottom()
try {
const payload = {
theme: text,
style: style.value || 'career',
length: 'medium',
characterInfo: '',
lifeEventsSummary: '',
useSocialInsights: useSocialInsights.value
}
const res = await createScriptWithDialogue(payload)
const data = res.data
scriptId.value = data.scriptId
conversationId.value = data.conversationId
currentVersionMessageId.value = data.currentVersionMessageId
currentResult.value = {
id: data.scriptId,
title: data.title,
theme: data.theme,
style: data.style,
length: data.length,
plotIntro: data.plotIntro,
plotTurning: data.plotTurning,
plotClimax: data.plotClimax,
plotEnding: data.plotEnding
}
await loadMessages()
viewMode.value = 'chat'
viewState.value = 'result'
analytics.track('script_generate_success', {
source,
style: currentResult.value.style || '',
length: currentResult.value.length || '',
use_social_insights: useSocialInsights.value,
duration_ms: Date.now() - generationStartedAt.value
}, { eventType: 'script', pagePath })
currentStreamTask.value = startNovelStream({
query: text,
onEvent: handleShortNovelEvent,
onError: (msg) => {
markGenerationFailed(msg)
analytics.track('script_generate_fail', {
source,
error: msg
}, { eventType: 'script', pagePath })
}
})
} catch (error) {
markGenerationFailed(error.message || '生成失败')
analytics.track('script_generate_fail', {
@@ -1634,10 +1672,82 @@ const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme }
}, { eventType: 'script', pagePath })
} finally {
generating.value = false
generationStatus.value = 'idle'
}
}
const handleShortNovelEvent = (event) => {
const { type, session_id, payload = {} } = event
if (session_id) novelSessionId.value = session_id
switch (type) {
case 'status':
generationStatus.value = payload.stage || 'processing'
break
case 'clarification_card':
clarificationCard.value = payload.card || null
viewState.value = 'clarifying'
break
case 'outline_created':
novelOutline.value = payload.outline || null
viewState.value = 'outlining'
break
case 'novel_start':
streamContent.value = ''
novelFullText.value = ''
viewState.value = 'novel-generating'
break
case 'novel_delta':
streamContent.value += payload.delta || ''
break
case 'novel_done':
novelFullText.value = payload.full_text || streamContent.value
streamContent.value = novelFullText.value
scriptId.value = payload.scriptId || ''
conversationId.value = payload.conversationId || ''
currentVersionMessageId.value = payload.currentVersionMessageId || ''
viewState.value = 'result'
viewMode.value = 'chat'
break
case 'error':
markGenerationFailed(payload.message || '生成失败')
break
}
}
const submitClarification = (answer) => {
if (answeringClarification.value) return
answeringClarification.value = true
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'answer_clarification',
payload: { answer },
onEvent: handleShortNovelEvent,
onError: (msg) => markGenerationFailed(msg)
})
setTimeout(() => { answeringClarification.value = false }, 200)
}
const confirmOutline = () => {
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'confirm_outline',
payload: null,
onEvent: handleShortNovelEvent,
onError: (msg) => markGenerationFailed(msg)
})
}
const modifyOutline = (feedback) => {
if (!feedback || !feedback.trim()) return
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'modify_outline',
payload: { feedback },
onEvent: handleShortNovelEvent,
onError: (msg) => markGenerationFailed(msg)
})
}
const submitWish = async (source = 'text') => {
const text = wishText.value.trim()
if (!text || generating.value) return
@@ -1962,6 +2072,162 @@ onUnmounted(() => {
</script>
<style scoped>
.clarifying-view {
min-height: 100vh;
padding: 40rpx 32rpx;
box-sizing: border-box;
}
.clarifying-scroll {
height: calc(100vh - 80rpx);
}
.clarifying-content {
padding: 40rpx 0;
}
.clarifying-intro {
margin-bottom: 32rpx;
}
.intro-title {
color: #ffffff;
font-size: 36rpx;
font-weight: 600;
}
.outlining-view {
min-height: 100vh;
padding: 40rpx 32rpx;
box-sizing: border-box;
}
.outlining-scroll {
height: calc(100vh - 200rpx);
}
.outline-card {
background: rgba(255, 255, 255, 0.08);
border-radius: 16rpx;
padding: 32rpx 28rpx;
margin-bottom: 32rpx;
}
.outline-title {
color: #ffffff;
font-size: 40rpx;
font-weight: 700;
margin-bottom: 16rpx;
}
.outline-logline {
color: rgba(255, 255, 255, 0.85);
font-size: 28rpx;
line-height: 1.6;
margin-bottom: 24rpx;
}
.outline-beats {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-bottom: 24rpx;
}
.beat-item {
display: flex;
gap: 20rpx;
align-items: flex-start;
}
.beat-order {
flex-shrink: 0;
width: 56rpx;
height: 56rpx;
background: #087e8b;
border-radius: 50%;
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.beat-content {
flex: 1;
display: flex;
flex-direction: column;
}
.beat-title {
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
}
.beat-summary {
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
margin-top: 8rpx;
line-height: 1.5;
}
.outline-ending {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 2rpx solid rgba(255, 255, 255, 0.1);
}
.ending-label {
color: rgba(255, 255, 255, 0.6);
font-size: 24rpx;
display: block;
margin-bottom: 8rpx;
}
.ending-text {
color: #ffffff;
font-size: 28rpx;
line-height: 1.6;
}
.outline-actions {
margin-top: 32rpx;
}
.outline-feedback {
width: 100%;
padding: 20rpx;
background: rgba(255, 255, 255, 0.05);
border-radius: 12rpx;
color: #ffffff;
font-size: 28rpx;
margin-bottom: 24rpx;
box-sizing: border-box;
}
.outline-buttons {
display: flex;
gap: 16rpx;
justify-content: flex-end;
}
.btn-secondary {
padding: 16rpx 32rpx;
background: rgba(255, 255, 255, 0.1);
border-radius: 32rpx;
color: #ffffff;
font-size: 28rpx;
}
.btn-primary {
padding: 16rpx 32rpx;
background: #087e8b;
border-radius: 32rpx;
color: #ffffff;
font-size: 28rpx;
}
.script-view {
position: relative;
height: 100%;