Files
happy-life-star/mini-program/src/pages/main/ScriptView.vue
T

3134 lines
86 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="script-view">
<view class="cosmic-background">
<view class="cosmic-stars layer-one"></view>
<view class="cosmic-stars layer-two"></view>
<view class="cosmic-planet planet-main"></view>
<view class="cosmic-planet planet-soft"></view>
<view class="meteor meteor-one"></view>
<view class="meteor meteor-two"></view>
<view class="meteor meteor-three"></view>
<view class="meteor meteor-four"></view>
<view class="meteor meteor-five"></view>
</view>
<view v-if="viewState === 'home'" class="wish-home">
<view class="home-head">
<view class="history-button" @click="openScriptLibrary">
<view class="history-lines">
<view></view>
<view></view>
<view></view>
</view>
<text>历史</text>
</view>
</view>
<view class="hero-copy">
<view class="hero-title-new">
<view class="hero-title-line">
<text>今天有什么</text>
<text class="hero-highlight">心愿</text>
</view>
<text class="hero-title-line hero-title-tail">想实现</text>
</view>
<text class="hero-title">今天有什么<text class="hero-highlight">心愿</text>想实现</text>
</view>
<view class="inspiration-section">
<view class="section-line">
<text class="section-title">灵感一下</text>
<text class="refresh" @click="shuffleInspirations">换一换</text>
</view>
<view class="recommend-grid">
<view
v-for="item in recommendations"
:key="item.text"
class="recommend-card"
@click="useRecommendation(item.text)"
>
<text class="recommend-text">{{ item.text }}</text>
</view>
</view>
</view>
<view class="orb-wrap">
<view
class="mic-orb"
:class="{ pressing: voiceState === 'pressing', recognizing: voiceState === 'recognizing' }"
@touchstart.prevent="startVoicePress"
@touchend.prevent="endVoicePress"
@touchcancel.prevent="cancelVoicePress"
>
<view class="mic-symbol">
<view class="mic-head"></view>
<view class="mic-stem"></view>
<view class="mic-base"></view>
</view>
</view>
</view>
<text class="voice-copy">{{ voiceCopy }}</text>
<view class="wish-input-wrap" :class="{ active: homeInputFocused, twoLine: homeInputLevel === 'two', expanded: homeInputLevel === 'multi' }">
<text v-if="!wishText" class="custom-input-placeholder home-input-placeholder">写下你的心愿AI帮你重写人生</text>
<textarea
class="wish-input"
v-model="wishText"
:auto-height="homeInputLevel !== 'single'"
:show-confirm-bar="false"
confirm-type="send"
maxlength="500"
@focus="homeInputFocused = true"
@blur="homeInputFocused = false"
@confirm="submitWish('text')"
/>
<view class="send-button" :class="{ disabled: !wishText.trim() }" @click="submitWish('text')">发送</view>
</view>
</view>
<view v-else-if="viewState === 'generating'" class="generation-view">
<scroll-view
class="generation-scroll"
scroll-y
:scroll-top="generationScrollTop"
:scroll-into-view="generationScrollTarget"
:scroll-with-animation="true"
:enhanced="true"
:show-scrollbar="false"
>
<view class="generation-scroll-content">
<view class="conversation">
<view class="chat-bubble user">
<text>{{ generationDisplayText }}</text>
<text class="bubble-time">{{ currentMessageTime }}</text>
</view>
<view class="chat-bubble system">
<text>{{ generationTitle }}</text>
<view v-if="showThinkingDots" class="thinking-dots">
<view></view>
<view></view>
<view></view>
</view>
<text v-if="generationStatus === 'failed'" class="generation-error">{{ generationFailureCopy }}</text>
<text v-else-if="visibleStreamContent" class="stream-preview typing">{{ visibleStreamContent }}<text v-if="generating" class="typing-cursor">|</text></text>
<text class="bubble-time">{{ currentMessageTime }}</text>
</view>
</view>
<view class="generation-loading">
<view class="loading-orbit" :class="{ streaming: generationStatus === 'streaming', failed: generationStatus === 'failed' }">
<view class="orbit-ring outer"></view>
<view class="orbit-ring inner"></view>
<view class="orbit-core"></view>
</view>
<text class="loading-copy">{{ generationCopy }}</text>
<text v-if="generationStatus !== 'failed'" class="loading-subcopy">{{ generationSubcopy }}</text>
<view v-else class="generation-actions">
<button class="generation-action primary" @click="retryGeneration">再试一次</button>
<button class="generation-action secondary" @click="returnToEdit">返回修改</button>
</view>
</view>
<view :id="generationScrollAnchor" class="generation-scroll-anchor"></view>
</view>
</scroll-view>
</view>
<view v-else class="result-page">
<view class="result-top-actions">
<view class="history-button" @click="openScriptLibrary">
<view class="history-lines">
<view></view>
<view></view>
<view></view>
</view>
<text>历史</text>
</view>
<button class="page-close-btn" @click="closeResult">×</button>
</view>
<scroll-view
class="result-view"
scroll-y
:scroll-top="resultCommandScrollTop"
:scroll-into-view="resultScrollTarget"
:scroll-with-animation="true"
:enhanced="true"
:show-scrollbar="false"
@scroll="handleResultScroll"
>
<view class="result-scroll-content">
<view id="result-scroll-top" class="result-scroll-top"></view>
<view class="conversation compact">
<view class="chat-bubble user">
<text>{{ wishText }}</text>
<text class="bubble-time">{{ currentMessageTime }}</text>
</view>
<view class="chat-bubble system done">
<text>心愿已实现故事已为你展开</text>
<text class="bubble-time">{{ currentResultTime }}</text>
</view>
</view>
<view class="story-card" :class="{ collapsed: storyCollapsed, dialogMode: resultHasScriptReplies }">
<view class="story-head">
<view class="story-title-wrap">
<text class="story-title">{{ currentResult?.title || '我的人生剧本' }}</text>
<view class="tag-row">
<text v-for="tag in resultTags" :key="tag" class="tag">{{ tag }}</text>
</view>
</view>
<view class="story-head-actions">
<button class="collapse-icon" @click="toggleStoryCollapse">
<text class="collapse-icon-text">{{ storyCollapsed ? '展开' : '收起' }}</text>
<view class="collapse-chevron" :class="{ down: storyCollapsed }">
<view></view>
<view></view>
</view>
</button>
<button class="copy-card-btn" @click="copyResultContent">复制</button>
</view>
</view>
<template v-if="!resultHasScriptReplies">
<scroll-view
v-if="storyCollapsed"
class="story-body-scroll"
scroll-y
:enhanced="true"
:show-scrollbar="true"
>
<text class="story-body" :selectable="true" :user-select="true">{{ displayedResultContent }}</text>
</scroll-view>
<text v-else class="story-body" :selectable="true" :user-select="true">{{ displayedResultContent }}</text>
<view class="collapse-row" @click="toggleStoryCollapse">
<text class="collapse-row-text">{{ storyCollapsed ? '展开全文' : '收起全文' }}</text>
<view class="collapse-chevron" :class="{ down: storyCollapsed }">
<view></view>
<view></view>
</view>
</view>
</template>
<view class="result-actions">
<button class="action-btn" @click="changeDirection">换个方向</button>
<button class="action-btn" @click="notLikeMe">不像我</button>
<button class="action-btn" @click="continueInChat">继续生成</button>
<button class="action-btn primary" @click="trackTtsClick">
<text class="action-icon">{{ ttsActionIcon }}</text>
<text>{{ ttsActionText }}</text>
</button>
</view>
</view>
<view v-if="resultMessages.length" class="result-chat-list">
<view
v-for="message in resultMessages"
:key="message.id"
class="chat-bubble"
:class="{ user: message.role === 'user', system: message.role === 'assistant', pending: message.pending }"
>
<text selectable user-select>{{ getMessageDisplayContent(message) }}</text>
<view v-if="message.pending" class="thinking-dots">
<view></view>
<view></view>
<view></view>
</view>
<view
v-if="isAssistantMessage(message)"
class="message-toggle"
@click.stop="toggleMessageCollapse(message)"
>
<text>{{ isMessageCollapsed(message) ? '展开' : '收起' }}</text>
<text class="message-toggle-icon" :class="{ open: !isMessageCollapsed(message) }"></text>
</view>
<text class="bubble-time">{{ message.time }}</text>
</view>
</view>
<view v-if="resultChatting" class="generation-loading">
<view class="loading-orbit streaming">
<view class="orbit-ring outer"></view>
<view class="orbit-ring inner"></view>
<view class="orbit-core"></view>
</view>
<text class="loading-copy">正在为你重写人生</text>
<text class="loading-subcopy">AI 正在思考下一步方向</text>
</view>
<view :id="resultScrollAnchor" class="result-scroll-anchor"></view>
</view>
</scroll-view>
<view v-if="showScrollTopBtn || showScrollBottomBtn" class="scroll-jump-group">
<view v-if="showScrollTopBtn" class="scroll-jump-btn" @click="scrollResultToTop">顶部</view>
<view v-if="showScrollBottomBtn" class="scroll-jump-btn" @click="scrollResultToBottom">底部</view>
</view>
<view class="result-chat-bar">
<view
class="chat-voice-btn"
:class="{ pressing: voiceState === 'pressing', recognizing: voiceState === 'recognizing' }"
@touchstart.prevent="startVoicePress"
@touchend.prevent="endVoicePress"
@touchcancel.prevent="cancelVoicePress"
>
<text>语音</text>
</view>
<view class="result-input-shell" :class="{ twoLine: resultInputLevel === 'two', expanded: resultInputLevel === 'multi' }">
<text v-if="!resultChatInput" class="custom-input-placeholder result-input-placeholder">继续提修改建议或确认后重新生成</text>
<textarea
class="result-chat-input"
v-model="resultChatInput"
:auto-height="resultInputLevel !== 'single'"
:show-confirm-bar="false"
:focus="resultInputFocus"
maxlength="500"
confirm-type="send"
@focus="resultInputFocused = true"
@blur="resultInputFocused = false"
@confirm="sendResultChat('text')"
/>
</view>
<view class="chat-send-btn" :class="{ disabled: !resultChatInput.trim() || resultChatting }" @click="sendResultChat('text')">发送</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useAppStore } from '../../stores/app.js'
import analytics from '../../services/analytics.js'
import * as epicScriptService from '../../services/epicScript.js'
import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
import { useTypewriterStream } from '../../composables/useTypewriterStream.js'
import { transcribeAudio } from '../../services/asr.js'
import { streamAiScene } from '../../services/aiRuntime.js'
const store = useAppStore()
const pagePath = '/pages/main/ScriptView'
const viewState = ref('home')
const wishText = ref('')
const voiceState = ref('idle')
const generationStartedAt = ref(0)
const currentResult = ref(null)
const currentMessageTime = ref('')
const currentResultTime = ref('')
const streamContent = ref('')
const generating = ref(false)
const streamWriter = useTypewriterStream({ interval: 32, step: 1 })
const resultChatWriter = useTypewriterStream({ interval: 32, step: 1 })
const generationStatus = ref('idle')
const generationError = ref('')
const generationHintIndex = ref(0)
const generationScrollAnchor = ref('generation-stream-anchor-a')
const generationScrollTarget = ref('')
const generationScrollTop = ref(0)
const generationAutoFollow = ref(true)
const generationDisplayText = ref('')
const lastGenerationPrompt = ref('')
const lastSubmitSource = ref('text')
const lastGenerationRevision = ref(false)
const homeInputFocused = ref(false)
const resultInputFocused = ref(false)
const resultInputFocus = ref(false)
const storyCollapsed = ref(false)
const resultChatInput = ref('')
const resultMessages = ref([])
const collapsedMessageIds = ref({})
const resultChatting = ref(false)
const resultScrollAnchor = ref('result-scroll-anchor-a')
const resultScrollTarget = ref('')
const resultScrollTop = ref(0)
const resultCommandScrollTop = ref(0)
const resultScrollHeight = ref(0)
const resultViewportHeight = ref(0)
const activeAssistantMessageId = ref('')
const revisionIntent = ref('')
const currentConversationId = ref('')
const remainingCount = ref(3)
const style = ref('career')
const randomRecommendations = ref([])
const useSocialInsights = ref(true)
const ttsPlayer = useTtsPlayer({ pagePath })
let recorderManager = null
let recordStartedAt = 0
let recordCancelled = false
let generationHintTimer = null
let generationSlowTimer = null
let generationVerySlowTimer = null
let generationScrollTimer = null
let resultScrollTargetTimer = null
let pendingOpenTimer = null
const generationHints = [
'正在从你的心愿里寻找故事的起点',
'正在整理人生素材和情绪线索',
'正在安排一次更爽的命运转弯',
'正在让故事慢慢靠近你'
]
watch(() => currentResult.value?.id, (sourceId, previousSourceId) => {
if (sourceId && sourceId !== previousSourceId) {
ttsPlayer.prewarmSource(sourceId)
}
})
const fallbackRecommendations = [
{ text: '如果老板今天突然夸我,我的人生会怎样展开?', tag: '职场逆袭' },
{ text: '我不再内耗,专注搞钱,逆袭成行业顶尖', tag: '成长' },
{ text: '重生回18岁,这次我要活成自己喜欢的样子', tag: '重生' },
{ text: '我终于被所有人看见,也被自己认可', tag: '被认可' }
]
const recommendations = computed(() => {
const source = randomRecommendations.value.length ? randomRecommendations.value : (store.inspirationRecommendations || [])
return source.length ? source.slice(0, 4) : fallbackRecommendations
})
const voiceCopy = computed(() => {
if (voiceState.value === 'pressing') return '松开后识别心愿'
if (voiceState.value === 'recognizing') return '正在识别你的心愿……'
if (voiceState.value === 'error') return '语音暂不可用,可以先输入文字'
return '按住说话,即刻如愿'
})
const resultTags = computed(() => {
const tags = currentResult.value?.tags
if (Array.isArray(tags) && tags.length) return tags.slice(0, 3)
const styleText = currentResult.value?.style || '爽文'
return [styleText, '成长', '被看见']
})
const resultContent = computed(() => {
return currentResult.value?.content || currentResult.value?.summary || '故事正在生成,请稍后查看。'
})
const getInputLevel = (text, singleThreshold = 18, twoLineThreshold = 42) => {
const value = String(text || '')
const trimmedLength = value.trim().length
const explicitLines = value.split('\n').length
if (!trimmedLength) return 'single'
if (explicitLines >= 3 || trimmedLength > twoLineThreshold) return 'multi'
if (explicitLines === 2 || trimmedLength > singleThreshold) return 'two'
return 'single'
}
const homeInputLevel = computed(() => getInputLevel(wishText.value, 20, 46))
const resultInputLevel = computed(() => getInputLevel(resultChatInput.value, 18, 42))
const displayedResultContent = computed(() => {
return resultContent.value || ''
})
const ASSISTANT_MESSAGE_PREVIEW_LENGTH = 120
const isAssistantMessage = (message) => {
return message?.role === 'assistant' && !message.pending
}
const isMessageCollapsed = (message) => {
if (!isAssistantMessage(message)) return false
return Boolean(collapsedMessageIds.value[message.id])
}
const getMessageDisplayContent = (message) => {
const content = String(message?.content || '')
if (!isMessageCollapsed(message)) return content
if (content.length <= ASSISTANT_MESSAGE_PREVIEW_LENGTH) return content
return `${content.slice(0, ASSISTANT_MESSAGE_PREVIEW_LENGTH)}...`
}
const toggleMessageCollapse = (message) => {
if (!isAssistantMessage(message)) return
collapsedMessageIds.value = {
...collapsedMessageIds.value,
[message.id]: !isMessageCollapsed(message)
}
nextTick(measureResultViewport)
}
const visibleStreamContent = computed(() => streamWriter.visibleText.value)
const resultCanScroll = computed(() => resultScrollHeight.value > resultViewportHeight.value + 24)
const resultAtTop = computed(() => resultScrollTop.value <= 24)
const resultAtBottom = computed(() => {
if (!resultCanScroll.value) return true
return resultScrollTop.value + resultViewportHeight.value >= resultScrollHeight.value - 36
})
const showScrollTopBtn = computed(() => resultCanScroll.value && !resultAtTop.value)
const showScrollBottomBtn = computed(() => resultCanScroll.value && !resultAtBottom.value)
const resultHasScriptReplies = computed(() => {
return resultMessages.value.some(item => item.role === 'assistant' && item.kind === 'script')
})
const measureResultViewport = () => {
nextTick(() => {
const query = uni.createSelectorQuery()
query.select('.result-view').boundingClientRect()
query.select('.result-scroll-content').boundingClientRect()
query.exec((rects = []) => {
const viewport = rects[0]
const content = rects[1]
if (viewport?.height) resultViewportHeight.value = viewport.height
if (content?.height) resultScrollHeight.value = content.height
})
})
}
const handleResultScroll = (event) => {
const detail = event?.detail || {}
resultScrollTop.value = Number(detail.scrollTop || 0)
resultScrollHeight.value = Number(detail.scrollHeight || resultScrollHeight.value || 0)
if (!resultViewportHeight.value) measureResultViewport()
}
const setResultScrollTarget = (target) => {
if (!target) return
if (resultScrollTargetTimer) {
clearTimeout(resultScrollTargetTimer)
resultScrollTargetTimer = null
}
resultScrollTarget.value = ''
nextTick(() => {
resultScrollTarget.value = target
resultScrollTargetTimer = setTimeout(() => {
if (resultScrollTarget.value === target) resultScrollTarget.value = ''
resultScrollTargetTimer = null
}, 480)
})
}
const pushResultScrollCommand = (top) => {
const nextTop = Number(top || 0)
resultCommandScrollTop.value = resultCommandScrollTop.value === nextTop
? nextTop + 1
: nextTop
}
const scrollGenerationToLatest = () => {
if (viewState.value !== 'generating') return
if (!generationAutoFollow.value) return
if (generationScrollTimer) return
generationScrollTimer = setTimeout(() => {
generationScrollTimer = null
if (viewState.value !== 'generating' || !generationAutoFollow.value) return
nextTick(() => {
generationScrollTop.value = generationScrollTop.value >= 100000000
? 100000
: generationScrollTop.value + 100000
generationScrollTarget.value = ''
generationScrollAnchor.value = generationScrollAnchor.value === 'generation-stream-anchor-a'
? 'generation-stream-anchor-b'
: 'generation-stream-anchor-a'
nextTick(() => {
generationScrollTarget.value = generationScrollAnchor.value
})
})
}, 48)
}
const keepGenerationAtBottom = () => {
;[0, 60, 140, 260, 420].forEach((delay) => {
setTimeout(scrollGenerationToLatest, delay)
})
}
const scrollResultToLatest = () => {
if (viewState.value !== 'result') return
nextTick(() => {
measureResultViewport()
pushResultScrollCommand(1000000)
resultScrollAnchor.value = resultScrollAnchor.value === 'result-scroll-anchor-a'
? 'result-scroll-anchor-b'
: 'result-scroll-anchor-a'
setResultScrollTarget(resultScrollAnchor.value)
})
}
const scrollResultToBottom = () => {
resultScrollTop.value = Math.max(0, resultScrollHeight.value - resultViewportHeight.value)
scrollResultToLatest()
}
const keepResultAtBottom = () => {
;[0, 80, 180, 360, 720].forEach((delay) => {
setTimeout(scrollResultToLatest, delay)
})
}
const scrollResultToTop = () => {
if (viewState.value !== 'result') return
resultScrollTop.value = 0
resultCommandScrollTop.value = 1
nextTick(() => {
resultCommandScrollTop.value = 0
setResultScrollTarget('result-scroll-top')
})
}
watch(visibleStreamContent, (text) => {
if (!text) return
scrollGenerationToLatest()
})
watch(generationStatus, () => {
scrollGenerationToLatest()
})
watch(viewState, (state) => {
if (state !== 'result') return
measureResultViewport()
})
watch(resultChatWriter.visibleText, (text) => {
if (!activeAssistantMessageId.value) return
const message = resultMessages.value.find(item => item.id === activeAssistantMessageId.value)
if (!message) return
message.content = text
keepResultAtBottom()
})
const generationTitle = computed(() => {
if (generationStatus.value === 'failed') return '心愿暂时没有抵达'
if (visibleStreamContent.value) return '故事正在展开'
return '心愿实现中……'
})
const showThinkingDots = computed(() => {
return generationStatus.value !== 'failed' && !visibleStreamContent.value
})
const generationCopy = computed(() => {
if (generationStatus.value === 'failed') return '这次生成没有顺利完成'
if (generationStatus.value === 'verySlow') return '这次需要久一点,仍在努力连接灵感'
if (generationStatus.value === 'slow') return '还在整理,请再给我一点时间'
if (streamWriter.isWaiting.value) return '正在理解你的心愿,整理人生素材'
if (streamWriter.isDraining.value) return '故事马上完成,正在收束最后一句'
if (streamWriter.isStreaming.value) return '正在把你的心愿写成故事'
return '正在把你的心愿写成故事'
})
const generationSubcopy = computed(() => {
if (generationStatus.value === 'verySlow') return '如果网络波动,稍后会给你温和提示,不会丢掉当前心愿。'
if (generationStatus.value === 'slow') return 'AI 还没有吐出第一句话,但请求仍在进行中。'
if (streamWriter.isStreaming.value || streamWriter.isDraining.value) return '已经收到内容,正在逐字展示给你。'
return generationHints[generationHintIndex.value]
})
const generationFailureCopy = computed(() => {
return generationError.value || '可能是网络慢了,或 AI 服务暂时没有回应。你可以直接再试一次,也可以返回修改心愿。'
})
const ttsActionText = computed(() => {
if (!currentResult.value?.id) return '播放'
if (ttsPlayer.loading.value) return '生成中'
return ttsPlayer.playing.value ? '暂停' : '播放'
})
const ttsActionIcon = computed(() => {
return ttsPlayer.playing.value ? 'Ⅱ' : '▶'
})
const formatMessageTime = () => {
const date = new Date()
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
const clearGenerationFeedbackTimers = () => {
if (generationHintTimer) {
clearInterval(generationHintTimer)
generationHintTimer = null
}
if (generationSlowTimer) {
clearTimeout(generationSlowTimer)
generationSlowTimer = null
}
if (generationVerySlowTimer) {
clearTimeout(generationVerySlowTimer)
generationVerySlowTimer = null
}
}
const startGenerationFeedback = () => {
clearGenerationFeedbackTimers()
generationStatus.value = 'waiting'
generationError.value = ''
generationHintIndex.value = 0
generationHintTimer = setInterval(() => {
if (generationStatus.value === 'failed' || visibleStreamContent.value) return
generationHintIndex.value = (generationHintIndex.value + 1) % generationHints.length
}, 3600)
generationSlowTimer = setTimeout(() => {
if (generating.value && !streamContent.value && generationStatus.value !== 'failed') {
generationStatus.value = 'slow'
}
}, 8000)
generationVerySlowTimer = setTimeout(() => {
if (generating.value && !streamContent.value && generationStatus.value !== 'failed') {
generationStatus.value = 'verySlow'
}
}, 20000)
}
const markGenerationStreaming = () => {
if (generationStatus.value === 'failed') return
generationStatus.value = 'streaming'
}
const markGenerationFailed = (message) => {
clearGenerationFeedbackTimers()
generationStatus.value = 'failed'
generationError.value = message || '可能是网络慢了,或 AI 服务暂时没有回应。你可以直接再试一次,也可以返回修改心愿。'
streamWriter.fail(generationError.value)
}
const normalizeGeneratedScript = (data) => {
const script = data?.script || data || {}
const latestScript = Array.isArray(store.scripts) && store.scripts.length ? store.scripts[0] : null
const merged = script?.id ? script : { ...latestScript, ...script }
const plotJson = merged?.plotJson || {}
const content = normalizeGeneratedContent(merged?.content || merged?.plotJson?.fullContent || merged?.summary || '')
return {
id: merged?.id || '',
title: merged?.title || wishText.value || '我的人生剧本',
theme: merged?.theme || wishText.value,
style: merged?.style || '爽文',
length: merged?.length || 'medium',
tags: merged?.tags || [merged?.style || '爽文', '成长', '被看见'],
summary: normalizeGeneratedContent(merged?.summary || content.slice(0, 90)),
content,
plotJson,
conversationId: merged?.conversationId || plotJson.conversationId || '',
parentScriptId: merged?.parentScriptId || plotJson.parentScriptId || '',
revisionIndex: Number(merged?.revisionIndex || plotJson.revisionIndex || 1),
revisionOf: merged?.revisionOf || plotJson.revisionOf || ''
}
}
const openScriptLibrary = () => {
analytics.track('script_history_click', {}, { eventType: 'script', pagePath })
uni.$emit('switchTab', 'library')
}
const useRecommendation = (text) => {
analytics.track('script_inspiration_select', {
source: 'recommendation',
prompt_length: text.length
}, { eventType: 'script', pagePath })
wishText.value = text
}
const shuffleInspirations = async () => {
analytics.track('script_inspiration_refresh', {
source: 'home'
}, { eventType: 'script', pagePath })
const list = await store.fetchRandomInspirations(4)
randomRecommendations.value = list.length ? list : fallbackRecommendations
}
const startVoicePress = () => {
if (generating.value) return
if (!recorderManager) {
voiceState.value = 'error'
analytics.track('script_voice_recognize_fail', {
reason: 'recorder_unavailable'
}, { eventType: 'script', pagePath })
uni.showToast({ title: '当前环境不支持录音', icon: 'none' })
setTimeout(() => {
if (voiceState.value === 'error') voiceState.value = 'idle'
}, 1800)
return
}
recordCancelled = false
recordStartedAt = Date.now()
voiceState.value = 'pressing'
analytics.track('script_voice_press_start', {}, { eventType: 'script', pagePath })
recorderManager.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 48000,
format: 'mp3'
})
}
const cancelVoicePress = () => {
recordCancelled = true
if (recorderManager && voiceState.value === 'pressing') {
recorderManager.stop()
} else {
voiceState.value = 'idle'
}
analytics.track('script_voice_record_cancel', {}, { eventType: 'script', pagePath })
}
const endVoicePress = async () => {
if (voiceState.value !== 'pressing') return
analytics.track('script_voice_press_end', {}, { eventType: 'script', pagePath })
voiceState.value = 'recognizing'
recorderManager?.stop()
}
const handleVoiceRecognizeSuccess = (text, durationMs) => {
if (viewState.value === 'result') {
resultChatInput.value = text
resultInputFocused.value = true
} else {
wishText.value = text
homeInputFocused.value = true
}
voiceState.value = 'idle'
analytics.track('script_voice_recognize_success', {
text_length: text.length,
duration_ms: durationMs || 0
}, { eventType: 'script', pagePath })
uni.showToast({ title: viewState.value === 'result' ? '识别成功,可继续修改建议' : '识别成功,可修改后发送', icon: 'none' })
}
const handleVoiceRecognizeFail = (reason, message = '语音识别失败,请重试') => {
voiceState.value = 'error'
analytics.track('script_voice_recognize_fail', {
reason
}, { eventType: 'script', pagePath })
uni.showToast({ title: message, icon: 'none' })
setTimeout(() => {
if (voiceState.value === 'error') voiceState.value = 'idle'
}, 1800)
}
const setupRecorder = () => {
if (!uni.getRecorderManager) return
recorderManager = uni.getRecorderManager()
recorderManager.onStop(async (res) => {
if (recordCancelled) {
voiceState.value = 'idle'
return
}
const duration = Date.now() - recordStartedAt
if (!res?.tempFilePath || duration < 500) {
handleVoiceRecognizeFail('record_too_short', '说话时间太短,请重试')
return
}
voiceState.value = 'recognizing'
try {
const response = await transcribeAudio(res.tempFilePath)
const text = response?.data?.text?.trim()
if (!text) {
handleVoiceRecognizeFail('empty_result', '没有识别到内容,请重试')
return
}
handleVoiceRecognizeSuccess(text, response?.data?.durationMs)
} catch (error) {
handleVoiceRecognizeFail(error?.message || error?.errMsg || 'upload_failed')
}
})
recorderManager.onError((error) => {
handleVoiceRecognizeFail(error?.errMsg || 'recorder_error', '录音失败,请检查麦克风权限')
})
}
const SCRIPT_CHAT_HISTORY_PREFIX = 'script_conversation_history_'
const LEGACY_SCRIPT_CHAT_HISTORY_PREFIX = 'script_chat_history_'
const PENDING_SCRIPT_CHAT_KEY = 'pending_open_script_chat'
const createMessageId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const createConversationId = () => `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const getCurrentScriptId = () => String(currentResult.value?.id || '')
const getCurrentConversationId = () => String(currentConversationId.value || currentResult.value?.conversationId || '')
const ensureConversationId = (script = currentResult.value) => {
const existing = String(script?.conversationId || script?.plotJson?.conversationId || currentConversationId.value || '')
currentConversationId.value = existing || createConversationId()
return currentConversationId.value
}
const getScriptChatHistoryKey = (conversationId = getCurrentConversationId()) => {
return conversationId ? `${SCRIPT_CHAT_HISTORY_PREFIX}${conversationId}` : ''
}
const readStoredMessages = (key) => {
if (!key) return []
const value = uni.getStorageSync(key)
return Array.isArray(value) ? value.filter(item => {
if (!item?.role) return false
if (item.role === 'script') return false
return typeof item.content === 'string'
}) : []
}
const readScriptChatHistory = ({ conversationId, scriptId } = {}) => {
const messages = readStoredMessages(getScriptChatHistoryKey(conversationId))
if (messages.length || !scriptId) return messages
return readStoredMessages(`${LEGACY_SCRIPT_CHAT_HISTORY_PREFIX}${scriptId}`)
}
const persistResultMessages = () => {
const key = getScriptChatHistoryKey()
if (!key) return
const messages = resultMessages.value
.filter(item => item?.role && item.role !== 'script' && !item.pending && item.content)
.map(item => ({
id: item.id,
role: item.role,
kind: item.kind || '',
content: item.content,
time: item.time
}))
uni.setStorageSync(key, messages)
}
const hydrateResultMessages = ({ conversationId, scriptId } = {}) => {
resultMessages.value = readScriptChatHistory({ conversationId, scriptId }).map(item => ({
id: item.id || createMessageId(),
role: item.role,
kind: item.kind || '',
content: item.content || '',
pending: false,
time: item.time || formatMessageTime()
}))
collapsedMessageIds.value = {}
}
const addResultMessage = ({ role, content, pending = false, kind = '' }) => {
const message = {
id: createMessageId(),
role,
kind,
content,
pending,
time: formatMessageTime()
}
resultMessages.value.push(message)
if (!pending) persistResultMessages()
keepResultAtBottom()
return message
}
const getScriptVersions = (script = currentResult.value) => {
const versions = script?.plotJson?.versions
return Array.isArray(versions) ? versions : []
}
const createScriptVersion = ({ script, content, revisionIndex, prompt, source }) => ({
id: `version-${revisionIndex}-${Date.now()}`,
revisionIndex,
title: script?.title || wishText.value || '我的人生剧本',
content,
summary: normalizeGeneratedContent(content).slice(0, 90),
prompt: prompt || '',
source: source || 'mini-program-stream',
createdAt: new Date().toISOString()
})
const buildVersionedPlotJson = ({ baseScript, content, conversationId, revisionIndex, prompt, source }) => {
const existingPlotJson = baseScript?.plotJson || {}
const existingVersions = Array.isArray(existingPlotJson.versions) ? existingPlotJson.versions : []
const nextVersion = createScriptVersion({
script: baseScript,
content,
revisionIndex,
prompt,
source
})
return {
...existingPlotJson,
mode: existingPlotJson.mode || 'inspiration',
prompt,
source,
fullContent: content,
conversationId,
currentRevisionIndex: revisionIndex,
versions: [...existingVersions, nextVersion]
}
}
const buildRevisionPrompt = (suggestion) => {
const history = resultMessages.value
.filter(item => item.role === 'user' || item.role === 'assistant')
.map(item => `${item.role === 'user' ? '用户' : 'AI'}${item.content}`)
.slice(-10)
.join('\n')
return [
'请基于同一个对话中的上一版小说和用户最新修改需求,重新生成一篇完整中文爽文小说。',
'要求:保留原始心愿的核心情绪,沿用上一版有效设定,只针对用户要求进行调衡和改写,输出完整正文,不要输出解释说明。',
`对话ID${getCurrentConversationId()}`,
`原始心愿:${wishText.value || currentResult.value?.theme || ''}`,
`上一版小说标题:${currentResult.value?.title || '我的人生剧本'}`,
`上一版小说正文:${resultContent.value || ''}`,
history ? `最近对话历史:\n${history}` : '',
`用户修改需求:${suggestion}`
].filter(Boolean).join('\n\n')
}
const buildChatPrompt = (message) => {
return [
'你是如愿星球里帮助用户修改爽文小说的中文创作 agent。',
'请围绕当前小说回应用户的修改意见,先帮助用户确认方向、给出简短建议,不要直接重写全文。',
'如果用户表达已确认要重新生成,请提醒用户发送“确认生成”或给出更明确的修改方向。',
`原始心愿:${wishText.value || ''}`,
`对话ID${getCurrentConversationId()}`,
`当前小说标题:${currentResult.value?.title || '我的人生剧本'}`,
`当前小说正文:${resultContent.value || ''}`,
`用户消息:${message}`
].join('\n\n')
}
const normalizeCommandText = (message) => {
return String(message || '')
.replace(/\s+/g, '')
.replace(/[,。!?、;:,.!?;:"'“”‘’]/g, '')
}
const shouldRegenerateFromMessage = (message) => {
const text = normalizeCommandText(message)
if (!text) return false
const hasRegenerateAction = /(\u751f\u6210|\u91cd\u5199|\u91cd\u65b0)/u.test(text)
const hasNegation = /(\u4e0d|\u522b|\u4e0d\u8981|\u5148\u4e0d|\u6682\u4e0d).*(\u751f\u6210|\u91cd\u5199|\u91cd\u65b0)/u.test(text)
if (hasRegenerateAction && hasNegation) return false
const exactCommands = new Set([
'\u786e\u8ba4\u751f\u6210',
'\u786e\u8ba4\u91cd\u5199',
'\u5f00\u59cb\u751f\u6210',
'\u5f00\u59cb\u91cd\u5199',
'\u91cd\u65b0\u751f\u6210',
'\u76f4\u63a5\u751f\u6210',
'\u5c31\u8fd9\u6837\u751f\u6210',
'\u5c31\u8fd9\u6837',
'\u751f\u6210',
'\u91cd\u5199'
])
return exactCommands.has(text)
|| /\u786e\u8ba4.*(\u751f\u6210|\u91cd\u5199|\u91cd\u65b0)/u.test(text)
|| /(\u5f00\u59cb|\u76f4\u63a5|\u91cd\u65b0).*(\u751f\u6210|\u91cd\u5199)/u.test(text)
}
const removeLeadingRepeatedPrefix = (text) => {
let output = String(text || '')
let changed = true
while (changed) {
changed = false
const maxPrefixLength = Math.floor(output.length / 2)
for (let size = maxPrefixLength; size >= 80; size -= 1) {
const prefix = output.slice(0, size)
if (output.slice(size).startsWith(prefix)) {
output = output.slice(size).trimStart()
changed = true
break
}
}
}
return output
}
const normalizeParagraphForCompare = (paragraph) => {
return String(paragraph || '')
.replace(/[#>*_`~\-【】「」『』“”"',。!?、;:,.!?;:\s]/g, '')
.trim()
}
const similarityRatio = (left, right) => {
if (!left || !right) return 0
const short = left.length <= right.length ? left : right
const long = left.length > right.length ? left : right
if (!short) return 0
if (long.includes(short)) return short.length / long.length
let same = 0
const max = Math.min(left.length, right.length)
for (let index = 0; index < max; index += 1) {
if (left[index] === right[index]) same += 1
}
return same / Math.max(left.length, right.length)
}
const isRepeatedParagraph = (current, previous) => {
const currentKey = normalizeParagraphForCompare(current)
const previousKey = normalizeParagraphForCompare(previous)
if (currentKey.length < 24 || previousKey.length < 24) return false
if (currentKey === previousKey) return true
return similarityRatio(currentKey, previousKey) >= 0.92
}
const removeRepeatedParagraphs = (text) => {
const paragraphs = String(text || '').split(/\n{2,}/)
const kept = []
const recent = []
paragraphs.forEach((paragraph) => {
const trimmed = paragraph.trim()
if (!trimmed) return
const repeated = recent.some((item) => isRepeatedParagraph(trimmed, item))
if (!repeated) {
kept.push(trimmed)
recent.push(trimmed)
if (recent.length > 8) recent.shift()
}
})
return kept.join('\n\n')
}
const normalizeGeneratedContent = (text) => {
return removeRepeatedParagraphs(removeLeadingRepeatedPrefix(String(text || '').trim()))
}
const openScriptChat = async (payload = {}) => {
uni.removeStorageSync(PENDING_SCRIPT_CHAT_KEY)
const scriptId = String(payload?.id || payload?.scriptId || '')
let script = payload?.script || (payload?.id ? payload : null)
if (!script?.id && scriptId) {
script = store.getScriptById(scriptId)
if (!script) {
await store.fetchScripts()
script = store.getScriptById(scriptId)
}
}
if (!script?.id) return
clearGenerationFeedbackTimers()
generating.value = false
generationStatus.value = 'idle'
generationError.value = ''
streamContent.value = ''
streamWriter.reset()
resultChatWriter.reset()
activeAssistantMessageId.value = ''
resultChatting.value = false
revisionIntent.value = ''
resultChatInput.value = ''
storyCollapsed.value = false
ttsPlayer.reset()
currentResult.value = normalizeGeneratedScript({ script })
currentConversationId.value = ensureConversationId(currentResult.value)
wishText.value = script.theme || script.prompt || script.title || '继续修改这个人生剧本'
currentMessageTime.value = formatMessageTime()
currentResultTime.value = script.updateTime || script.updatedAt || script.createTime || script.createdAt || formatMessageTime()
hydrateResultMessages({ conversationId: currentConversationId.value, scriptId: script.id })
viewState.value = 'result'
analytics.track('script_chat_open_from_library', {
script_id: script.id,
has_history: resultMessages.value.length > 0
}, { eventType: 'script', pagePath })
scrollResultToLatest()
}
const openPendingScriptChat = async () => {
const pending = uni.getStorageSync(PENDING_SCRIPT_CHAT_KEY)
if (!pending) return
uni.removeStorageSync(PENDING_SCRIPT_CHAT_KEY)
await openScriptChat(pending)
}
const handleOpenScriptChat = (payload) => {
openScriptChat(payload)
}
const saveGeneratedScriptContent = async ({
prompt,
content,
saveTheme,
conversationId,
revisionIndex = 1,
updateScriptId = '',
parentScriptId = '',
revisionOf = ''
}) => {
const profile = store.userProfile || store.registrationData || {}
const characterInfo = epicScriptService.buildCharacterInfo(profile)
const lifeEventsSummary = epicScriptService.buildLifeEventsSummary(store.events || [], profile)
const theme = saveTheme || prompt
const baseScript = updateScriptId ? currentResult.value : {
title: theme.length > 22 ? `${theme.slice(0, 22)}...` : theme,
theme,
style: style.value,
length: 'medium',
plotJson: {}
}
const title = baseScript?.title || (theme.length > 22 ? `${theme.slice(0, 22)}...` : theme)
const plotJson = buildVersionedPlotJson({
baseScript,
content,
conversationId,
revisionIndex,
prompt,
source: 'mini-program-stream'
})
const payload = {
id: updateScriptId || undefined,
title,
theme,
style: baseScript?.style || style.value,
length: baseScript?.length || 'medium',
content,
plotJson,
conversationId,
parentScriptId,
revisionIndex,
revisionOf,
characterInfo,
lifeEventsSummary,
useSocialInsights: useSocialInsights.value
}
const saveRes = updateScriptId
? await store.updateScript(payload)
: await store.createScript(payload)
if (!saveRes.success) {
throw new Error(saveRes.error || (updateScriptId ? '更新剧本失败' : '保存剧本失败'))
}
return {
success: true,
data: {
script: saveRes.data,
remainingCount: remainingCount.value
}
}
}
const generateScriptByStream = async (text, options = {}) => {
const saveTheme = options.saveTheme || text
const conversationId = options.conversationId || ensureConversationId()
const revisionIndex = options.revisionIndex || 1
const updateScriptId = options.updateScriptId || ''
const streamRes = await streamAiScene({
sceneCode: 'script_generate',
inputs: {
prompt: text,
style: style.value,
length: 'medium',
useSocialInsights: useSocialInsights.value
},
onDelta: (_delta, output) => {
const preview = normalizeGeneratedContent(output)
streamContent.value = preview
markGenerationStreaming()
streamWriter.push(preview)
scrollGenerationToLatest()
},
onDone: (_event, output) => {
streamWriter.finish(normalizeGeneratedContent(output))
keepGenerationAtBottom()
},
onError: (message) => {
streamWriter.fail(message || 'AI 流式生成失败')
}
})
const content = normalizeGeneratedContent(streamRes.output)
if (!content) {
throw new Error('AI 流式输出为空')
}
streamWriter.finish(content)
await streamWriter.waitForDone()
return await saveGeneratedScriptContent({
prompt: text,
content,
saveTheme,
conversationId,
revisionIndex,
parentScriptId: options.parentScriptId || '',
revisionOf: options.revisionOf || '',
updateScriptId
})
}
const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme, revision = false }) => {
const text = String(prompt || '').trim()
const display = String(displayText || text).trim()
if (!text || generating.value) return
const isRevision = Boolean(revision)
const conversationId = isRevision ? ensureConversationId() : createConversationId()
const parentScriptId = isRevision ? getCurrentScriptId() : ''
const revisionIndex = isRevision
? getScriptVersions().length + 1
: 1
currentConversationId.value = conversationId
lastGenerationRevision.value = isRevision
lastSubmitSource.value = source
generationDisplayText.value = display
lastGenerationPrompt.value = text
analytics.track('script_wish_submit', {
source,
prompt_length: display.length
}, { eventType: 'script', pagePath })
currentMessageTime.value = formatMessageTime()
generationStartedAt.value = Date.now()
generating.value = true
streamContent.value = ''
generationScrollTarget.value = ''
generationScrollTop.value = 0
generationScrollAnchor.value = 'generation-stream-anchor-a'
generationAutoFollow.value = true
streamWriter.reset()
startGenerationFeedback()
ttsPlayer.reset()
viewState.value = 'generating'
keepGenerationAtBottom()
analytics.track('script_generation_progress_view', {
source,
prompt_length: display.length
}, { eventType: 'script', pagePath })
let res
try {
res = await generateScriptByStream(text, {
saveTheme: saveTheme || display,
conversationId,
parentScriptId,
revisionIndex,
revisionOf: parentScriptId,
updateScriptId: isRevision ? getCurrentScriptId() : ''
})
} catch (streamError) {
markGenerationFailed(streamError?.message || 'AI 流式生成失败')
analytics.track('script_generate_stream_fail', {
source,
error: streamError?.message || 'stream_failed'
}, { eventType: 'script', pagePath })
res = {
success: false,
error: streamError?.message || 'AI 流式生成失败'
}
}
generating.value = false
if (res.success) {
clearGenerationFeedbackTimers()
if (streamContent.value) streamWriter.finish(normalizeGeneratedContent(streamContent.value))
keepGenerationAtBottom()
}
if (!res.success) {
analytics.track('script_generate_fail', {
source,
error: res.error || 'unknown',
duration_ms: Date.now() - generationStartedAt.value
}, { eventType: 'script', pagePath })
markGenerationFailed(res.error || '生成失败')
return
}
currentResult.value = normalizeGeneratedScript(res.data)
currentConversationId.value = ensureConversationId(currentResult.value)
currentResultTime.value = formatMessageTime()
if (!isRevision) {
resultMessages.value = []
collapsedMessageIds.value = {}
}
if (typeof res.data?.remainingCount === 'number') remainingCount.value = res.data.remainingCount
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 })
analytics.track('script_result_view', {
script_id: currentResult.value.id || '',
style: currentResult.value.style || '',
length: currentResult.value.length || ''
}, { eventType: 'script', pagePath })
generationStatus.value = 'idle'
viewState.value = 'result'
keepResultAtBottom()
}
const submitWish = async (source = 'text') => {
const text = wishText.value.trim()
if (!text || generating.value) return
await runGeneration({ prompt: text, displayText: text, source, saveTheme: text })
}
const retryGeneration = () => {
if (generating.value) return
analytics.track('script_generation_retry_click', {
prompt_length: wishText.value.trim().length
}, { eventType: 'script', pagePath })
generationStatus.value = 'idle'
generationError.value = ''
runGeneration({
prompt: lastGenerationPrompt.value || wishText.value.trim(),
displayText: wishText.value.trim(),
source: lastSubmitSource.value || 'text',
saveTheme: wishText.value.trim(),
revision: lastGenerationRevision.value
})
}
const returnToEdit = () => {
clearGenerationFeedbackTimers()
generating.value = false
generationStatus.value = 'idle'
generationError.value = ''
streamContent.value = ''
streamWriter.reset()
analytics.track('script_generation_back_edit_click', {
prompt_length: wishText.value.trim().length
}, { eventType: 'script', pagePath })
viewState.value = 'home'
}
const closeResult = () => {
clearGenerationFeedbackTimers()
generationStatus.value = 'idle'
viewState.value = 'home'
currentResult.value = null
currentConversationId.value = ''
resultMessages.value = []
collapsedMessageIds.value = {}
resultChatInput.value = ''
revisionIntent.value = ''
storyCollapsed.value = false
ttsPlayer.reset()
}
const toggleStoryCollapse = () => {
storyCollapsed.value = !storyCollapsed.value
analytics.track('script_result_story_collapse_toggle', {
script_id: currentResult.value?.id || '',
collapsed: storyCollapsed.value
}, { eventType: 'script', pagePath })
}
const copyResultContent = () => {
const content = resultContent.value || ''
if (!content.trim()) {
uni.showToast({ title: '暂无可复制内容', icon: 'none' })
return
}
uni.setClipboardData({
data: content,
success: () => {
uni.showToast({ title: '已复制全文', icon: 'success' })
}
})
analytics.track('script_result_copy_click', {
script_id: currentResult.value?.id || '',
content_length: content.length
}, { eventType: 'script', pagePath })
}
const continueInChat = () => {
storyCollapsed.value = true
resultInputFocus.value = true
setTimeout(() => {
resultInputFocus.value = false
}, 300)
scrollResultToLatest()
analytics.track('script_result_continue_click', {
script_id: currentResult.value?.id || ''
}, { eventType: 'script', pagePath })
}
const enterRevisionConfirm = (intent, copy) => {
revisionIntent.value = intent
resultChatInput.value = ''
addResultMessage({
role: 'assistant',
content: copy
})
scrollResultToLatest()
}
const changeDirection = () => {
analytics.track('script_result_change_direction_click', {
script_id: currentResult.value?.id || ''
}, { eventType: 'script', pagePath })
enterRevisionConfirm('change_direction', '我先不直接重写。你想换成更事业逆袭、更情感治愈,还是更强反转的方向?告诉我一句,我确认后再重新生成。')
}
const notLikeMe = () => {
analytics.track('script_result_not_like_me_click', {
script_id: currentResult.value?.id || ''
}, { eventType: 'script', pagePath })
enterRevisionConfirm('not_like_me', '收到,我会先帮你确认“不像你”的地方。你可以说哪里不贴近你,比如性格、经历、情绪或结局,我再基于当前故事重写。')
}
const regenerateFromRevision = async (message, source = 'result_chat') => {
const conversationId = ensureConversationId()
const prompt = buildRevisionPrompt(message)
const parentScriptId = getCurrentScriptId()
const revisionIndex = getScriptVersions().length + 1
revisionIntent.value = ''
resultChatInput.value = ''
resultChatting.value = true
storyCollapsed.value = false
resultChatWriter.reset()
const assistant = addResultMessage({
role: 'assistant',
kind: 'script',
content: '',
pending: true
})
activeAssistantMessageId.value = assistant.id
let generatedContent = ''
try {
const response = await streamAiScene({
sceneCode: 'script_generate',
inputs: {
prompt,
style: style.value,
length: 'medium',
useSocialInsights: useSocialInsights.value
},
onDelta: (_delta, output) => {
const normalized = normalizeGeneratedContent(output)
// 避免内容回退时重置导致的闪烁;只推进增量内容
if (normalized.length >= resultChatWriter.visibleText.value.length) {
resultChatWriter.push(normalized)
}
keepResultAtBottom()
},
onDone: (_event, output) => {
resultChatWriter.finish(normalizeGeneratedContent(output))
keepResultAtBottom()
},
onError: (errorMessage) => {
resultChatWriter.fail(errorMessage || 'AI 流式生成失败')
}
})
generatedContent = normalizeGeneratedContent(response.output)
if (!generatedContent) {
throw new Error('AI 流式输出为空')
}
resultChatWriter.finish(generatedContent)
await resultChatWriter.waitForDone()
const current = resultMessages.value.find(item => item.id === assistant.id)
if (current) current.content = generatedContent
const saveRes = await saveGeneratedScriptContent({
prompt,
content: generatedContent,
saveTheme: wishText.value || currentResult.value?.theme || currentResult.value?.title || '根据当前故事继续修改',
conversationId,
revisionIndex,
updateScriptId: parentScriptId,
parentScriptId,
revisionOf: parentScriptId
})
currentResult.value = normalizeGeneratedScript(saveRes.data)
currentConversationId.value = ensureConversationId(currentResult.value)
currentResultTime.value = formatMessageTime()
if (typeof saveRes.data?.remainingCount === 'number') remainingCount.value = saveRes.data.remainingCount
analytics.track('script_generate_success', {
source,
style: currentResult.value.style || '',
length: currentResult.value.length || '',
use_social_insights: useSocialInsights.value,
duration_ms: 0
}, { eventType: 'script', pagePath })
} catch (error) {
resultChatWriter.fail(error?.message || '这次生成没有成功,可以稍后再试。')
const current = resultMessages.value.find(item => item.id === assistant.id)
if (current && !current.content) {
current.content = error?.message || '这次生成没有成功,可以稍后再试。'
}
analytics.track('script_generate_fail', {
source,
error: error?.message || 'unknown'
}, { eventType: 'script', pagePath })
} finally {
const current = resultMessages.value.find(item => item.id === assistant.id)
if (current) {
current.pending = false
current.kind = generatedContent ? 'script' : ''
if (!current.content && resultChatWriter.visibleText.value) current.content = resultChatWriter.visibleText.value
}
activeAssistantMessageId.value = ''
resultChatting.value = false
persistResultMessages()
keepResultAtBottom()
}
}
const sendResultChat = async (source = 'text') => {
const message = resultChatInput.value.trim()
if (!message || resultChatting.value || generating.value) return
resultChatInput.value = ''
addResultMessage({ role: 'user', content: message })
analytics.track('script_result_chat_send', {
source,
script_id: currentResult.value?.id || '',
has_revision_intent: Boolean(revisionIntent.value),
message_length: message.length
}, { eventType: 'script', pagePath })
if (revisionIntent.value || shouldRegenerateFromMessage(message)) {
await regenerateFromRevision(message, source)
return
}
resultChatting.value = true
resultChatWriter.reset()
const assistant = addResultMessage({
role: 'assistant',
content: '',
pending: true
})
activeAssistantMessageId.value = assistant.id
try {
const response = await streamAiScene({
sceneCode: 'script_generate',
inputs: {
prompt: buildChatPrompt(message),
style: style.value,
length: 'short',
useSocialInsights: false
},
onDelta: (_delta, output) => {
const text = String(output || '')
if (text.length >= resultChatWriter.visibleText.value.length) {
resultChatWriter.push(text)
}
keepResultAtBottom()
},
onDone: (_event, output) => {
resultChatWriter.finish(String(output || ''))
keepResultAtBottom()
},
onError: (errorMessage) => {
resultChatWriter.fail(errorMessage || '暂时没有收到回复')
}
})
const output = response.output?.trim() || '我收到了。你可以继续补充,也可以直接说“确认生成”。'
resultChatWriter.finish(output)
await resultChatWriter.waitForDone()
const current = resultMessages.value.find(item => item.id === assistant.id)
if (current) current.content = output
} catch (error) {
resultChatWriter.fail(error?.message || '这次回复没有成功,可以稍后再试。')
const current = resultMessages.value.find(item => item.id === assistant.id)
if (current && !current.content) {
current.content = error?.message || '这次回复没有成功,可以稍后再试。'
}
} finally {
const current = resultMessages.value.find(item => item.id === assistant.id)
if (current) {
current.pending = false
if (!current.content && resultChatWriter.visibleText.value) {
current.content = resultChatWriter.visibleText.value
}
}
activeAssistantMessageId.value = ''
resultChatting.value = false
persistResultMessages()
keepResultAtBottom()
}
}
const trackTtsClick = () => {
analytics.track('script_result_tts_click', {
script_id: currentResult.value?.id || ''
}, { eventType: 'tts', pagePath })
ttsPlayer.playSource(currentResult.value?.id || '')
}
onMounted(() => {
analytics.track('script_home_view', {}, { eventType: 'script', pagePath })
setupRecorder()
uni.$on('openScriptChat', handleOpenScriptChat)
pendingOpenTimer = setTimeout(openPendingScriptChat, 300)
})
onUnmounted(() => {
clearGenerationFeedbackTimers()
if (generationScrollTimer) {
clearTimeout(generationScrollTimer)
generationScrollTimer = null
}
if (resultScrollTargetTimer) {
clearTimeout(resultScrollTargetTimer)
resultScrollTargetTimer = null
}
if (pendingOpenTimer) {
clearTimeout(pendingOpenTimer)
pendingOpenTimer = null
}
streamWriter.dispose()
resultChatWriter.dispose()
uni.$off('openScriptChat', handleOpenScriptChat)
if (recorderManager && voiceState.value === 'pressing') {
recordCancelled = true
recorderManager.stop()
}
})
</script>
<style scoped>
.script-view {
position: relative;
height: 100%;
min-height: 100%;
overflow: hidden;
color: #fff;
font-family: "PingFang SC", "Noto Sans SC", sans-serif;
}
.cosmic-background {
position: absolute;
inset: -120rpx -140rpx;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.cosmic-stars {
position: absolute;
inset: 0;
opacity: 0.48;
}
.cosmic-stars.layer-one {
background:
radial-gradient(circle at 12% 18%, rgba(255, 255, 255, 0.52) 0 2rpx, transparent 3rpx),
radial-gradient(circle at 32% 9%, rgba(255, 216, 107, 0.42) 0 2rpx, transparent 3rpx),
radial-gradient(circle at 74% 32%, rgba(255, 255, 255, 0.36) 0 2rpx, transparent 3rpx),
radial-gradient(circle at 88% 48%, rgba(209, 138, 255, 0.42) 0 2rpx, transparent 3rpx),
radial-gradient(circle at 24% 72%, rgba(255, 255, 255, 0.32) 0 2rpx, transparent 3rpx);
animation: starFloat 8s ease-in-out infinite;
}
.cosmic-stars.layer-two {
opacity: 0.32;
background:
radial-gradient(circle at 18% 42%, rgba(255, 255, 255, 0.5) 0 1rpx, transparent 2rpx),
radial-gradient(circle at 46% 28%, rgba(209, 138, 255, 0.5) 0 1rpx, transparent 2rpx),
radial-gradient(circle at 64% 70%, rgba(255, 216, 107, 0.5) 0 1rpx, transparent 2rpx),
radial-gradient(circle at 92% 18%, rgba(255, 255, 255, 0.42) 0 1rpx, transparent 2rpx);
animation: starFloat 11s ease-in-out infinite reverse;
}
.cosmic-planet {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.planet-main {
top: 210rpx;
right: -120rpx;
width: 260rpx;
height: 260rpx;
opacity: 0.18;
background:
radial-gradient(circle at 42% 42%, rgba(209, 138, 255, 0.5), rgba(93, 38, 193, 0.22) 42%, transparent 72%);
filter: blur(2rpx);
box-shadow: 0 0 120rpx rgba(168, 85, 247, 0.22);
animation: planetDrift 9s ease-in-out infinite;
}
.planet-main::after {
content: '';
position: absolute;
left: 14rpx;
top: 120rpx;
width: 228rpx;
height: 30rpx;
border-radius: 50%;
border: 3rpx solid rgba(255, 216, 107, 0.08);
transform: rotate(-16deg);
}
.planet-soft {
left: -64rpx;
bottom: 180rpx;
width: 128rpx;
height: 128rpx;
opacity: 0.2;
background: radial-gradient(circle at 50% 42%, rgba(140, 68, 242, 0.78), rgba(30, 8, 76, 0.92));
box-shadow: 0 0 64rpx rgba(140, 68, 242, 0.28);
animation: planetDrift 12s ease-in-out infinite reverse;
}
.meteor {
position: absolute;
width: 160rpx;
height: 4rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, transparent 0%, rgba(132, 92, 255, 0.12) 18%, rgba(255, 216, 107, 0.34) 58%, rgba(255, 255, 255, 0.95) 100%);
opacity: 0;
transform: rotate(22deg);
animation: meteorFall 5.6s cubic-bezier(0.18, 0.65, 0.42, 1) infinite;
box-shadow: 0 0 18rpx rgba(255, 216, 107, 0.14);
}
.meteor::after {
content: '';
position: absolute;
right: -5rpx;
top: 50%;
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.96);
box-shadow:
0 0 14rpx rgba(255, 255, 255, 0.74),
0 0 28rpx rgba(255, 216, 107, 0.34);
transform: translateY(-50%);
}
.meteor-one {
top: 120rpx;
left: -190rpx;
}
.meteor-two {
top: 260rpx;
left: 46rpx;
width: 126rpx;
animation-delay: 1.8s;
}
.meteor-three {
top: 440rpx;
left: 420rpx;
width: 118rpx;
animation-delay: 3.4s;
}
.meteor-four {
top: 360rpx;
left: -210rpx;
width: 210rpx;
height: 5rpx;
opacity: 0;
animation-duration: 7.2s;
animation-delay: 4.6s;
}
.meteor-five {
top: 720rpx;
left: -180rpx;
width: 148rpx;
animation-duration: 6.4s;
animation-delay: 2.8s;
}
@keyframes starFloat {
0%, 100% {
transform: translateY(0);
opacity: 0.36;
}
50% {
transform: translateY(18rpx);
opacity: 0.62;
}
}
@keyframes planetDrift {
0%, 100% {
transform: translate3d(0, 0, 0) scale(1);
}
50% {
transform: translate3d(-14rpx, 18rpx, 0) scale(1.03);
}
}
@keyframes meteorFall {
0% {
opacity: 0;
transform: translate3d(0, 0, 0) rotate(22deg);
}
8% {
opacity: 0.76;
}
32% {
opacity: 0.18;
}
38% {
opacity: 0;
transform: translate3d(660rpx, 270rpx, 0) rotate(22deg);
}
100% {
opacity: 0;
transform: translate3d(660rpx, 270rpx, 0) rotate(22deg);
}
}
@keyframes micPulse {
0%, 100% {
opacity: 0.22;
transform: scale(0.92);
}
50% {
opacity: 0.48;
transform: scale(1.02);
}
}
@keyframes micHaloBreath {
0%, 100% {
opacity: 0.76;
transform: scale(0.94);
}
50% {
opacity: 1;
transform: scale(1.04);
}
}
@keyframes micIdleBreath {
0%, 100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-4rpx) scale(1.015);
}
}
@keyframes micPressBreath {
0%, 100% {
transform: scale(1.055);
}
50% {
transform: scale(1.085);
}
}
@keyframes micWavePress {
0% {
opacity: 0.58;
transform: scale(0.82);
}
100% {
opacity: 0;
transform: scale(1.22);
}
}
@keyframes micScan {
0% {
background-position: -180rpx 0, 0 0, 0 0;
}
100% {
background-position: 180rpx 0, 0 0, 0 0;
}
}
@keyframes micHighlightFloat {
0%, 100% {
opacity: 0.24;
transform: translateY(0);
}
50% {
opacity: 0.42;
transform: translateY(8rpx);
}
}
.wish-home,
.generation-view,
.result-page {
position: relative;
z-index: 1;
min-height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: 4rpx 28rpx 24rpx;
}
.wish-home {
gap: 36rpx;
padding-bottom: 28rpx;
}
.result-page {
height: 100%;
min-height: 0;
}
.result-view {
position: relative;
z-index: 1;
flex: 1;
height: 100%;
min-height: 0;
}
.result-scroll-content {
min-height: 100%;
box-sizing: border-box;
padding: 0 0 220rpx;
}
.result-scroll-top {
width: 1rpx;
height: 1rpx;
}
.result-top-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18rpx;
}
.page-close-btn {
width: 64rpx;
height: 64rpx;
line-height: 60rpx;
padding: 0;
border-radius: 50%;
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.06);
border: 1rpx solid rgba(192, 132, 252, 0.36);
font-size: 42rpx;
backdrop-filter: blur(18rpx);
-webkit-backdrop-filter: blur(18rpx);
}
.page-close-btn::after {
border: 0;
}
.home-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14rpx;
}
.history-button {
height: 58rpx;
display: inline-flex;
align-items: center;
gap: 12rpx;
padding: 0 20rpx;
border-radius: 999rpx;
color: rgba(232, 204, 255, 0.92);
font-size: 24rpx;
font-weight: 800;
background: rgba(43, 19, 83, 0.46);
border: 1rpx solid rgba(168, 85, 247, 0.24);
}
.history-lines {
width: 28rpx;
display: flex;
flex-direction: column;
gap: 5rpx;
}
.history-lines view {
height: 3rpx;
border-radius: 999rpx;
background: currentColor;
}
.hero-copy {
margin-top: 26rpx;
margin-bottom: 30rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
text-align: center;
overflow: visible;
}
.hero-title {
display: none;
}
.hero-title-new {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
}
.hero-title-line {
display: flex;
align-items: baseline;
justify-content: center;
white-space: nowrap;
font-size: 60rpx;
font-weight: 800;
line-height: 1.18;
letter-spacing: 0;
}
.hero-title-tail {
font-size: 58rpx;
}
.hero-highlight {
margin: 0 6rpx;
color: #d18aff;
text-shadow: 0 0 28rpx rgba(209, 138, 255, 0.52);
}
.orb-wrap {
position: relative;
height: 300rpx;
margin-top: 44rpx;
margin-bottom: 0;
display: flex;
align-items: center;
justify-content: center;
}
.orb-wrap::before {
content: '';
position: absolute;
width: 420rpx;
height: 420rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(116, 41, 210, 0.42), transparent 64%);
filter: blur(6rpx);
animation: micHaloBreath 3.8s ease-in-out infinite;
}
.mic-orb {
position: relative;
width: 260rpx;
height: 260rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #f1a0ff 0%, #934dff 48%, #4d1ccb 100%);
box-shadow:
0 0 72rpx rgba(169, 85, 247, 0.75),
0 0 180rpx rgba(102, 41, 201, 0.55);
transition: transform 0.18s ease, box-shadow 0.18s ease;
overflow: hidden;
animation: micIdleBreath 3.2s ease-in-out infinite;
}
.mic-orb::before {
content: '';
position: absolute;
inset: 18rpx;
border-radius: 50%;
background:
radial-gradient(circle at 35% 26%, rgba(255, 255, 255, 0.28), transparent 18%),
radial-gradient(circle at 50% 62%, rgba(39, 16, 98, 0.18), transparent 48%);
border: 1rpx solid rgba(255, 255, 255, 0.1);
}
.mic-orb::after {
content: '';
position: absolute;
inset: -18rpx;
border-radius: 50%;
border: 3rpx solid rgba(216, 180, 254, 0.12);
animation: micPulse 2.8s ease-in-out infinite;
}
.mic-orb.pressing {
animation: micPressBreath 1.1s ease-in-out infinite;
transform: scale(1.06);
box-shadow:
0 0 86rpx rgba(241, 160, 255, 0.82),
0 0 220rpx rgba(102, 41, 201, 0.68);
}
.mic-orb.pressing::after {
border-color: rgba(255, 216, 107, 0.26);
animation: micWavePress 0.95s ease-out infinite;
}
.mic-orb.recognizing {
opacity: 0.86;
}
.mic-orb.recognizing::before {
background:
linear-gradient(110deg, transparent 0 34%, rgba(255, 255, 255, 0.24) 44%, transparent 56%),
radial-gradient(circle at 35% 26%, rgba(255, 255, 255, 0.28), transparent 18%),
radial-gradient(circle at 50% 62%, rgba(39, 16, 98, 0.18), transparent 48%);
animation: micScan 1.35s linear infinite;
}
.mic-symbol {
position: relative;
width: 118rpx;
height: 156rpx;
z-index: 2;
}
.mic-head {
position: relative;
width: 66rpx;
height: 106rpx;
margin: 0 auto;
border-radius: 999rpx;
border: 10rpx solid rgba(255, 255, 255, 0.96);
box-sizing: border-box;
background: transparent;
box-shadow:
inset 0 0 14rpx rgba(255, 255, 255, 0.12),
0 0 16rpx rgba(255, 255, 255, 0.22);
}
.mic-head::before {
content: '';
position: absolute;
left: 9rpx;
top: 16rpx;
width: 8rpx;
height: 34rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.18);
animation: micHighlightFloat 2.6s ease-in-out infinite;
}
.mic-symbol::after {
content: '';
position: absolute;
left: 8rpx;
top: 56rpx;
width: 102rpx;
height: 70rpx;
box-sizing: border-box;
border-left: 10rpx solid rgba(255, 255, 255, 0.95);
border-right: 10rpx solid rgba(255, 255, 255, 0.95);
border-bottom: 10rpx solid rgba(255, 255, 255, 0.95);
border-radius: 0 0 64rpx 64rpx;
box-shadow: 0 0 14rpx rgba(255, 255, 255, 0.18);
}
.mic-stem {
width: 12rpx;
height: 34rpx;
margin: -4rpx auto 0;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 0 12rpx rgba(255, 255, 255, 0.2);
}
.mic-base {
width: 76rpx;
height: 12rpx;
margin: 0 auto;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 0 12rpx rgba(255, 255, 255, 0.2);
}
.voice-copy {
margin-top: -2rpx;
margin-bottom: 46rpx;
text-align: center;
font-size: 34rpx;
font-weight: 500;
line-height: 56rpx;
color: rgba(255, 255, 255, 0.92);
}
.wish-input-wrap {
position: relative;
min-height: 92rpx;
margin-top: 0;
margin-bottom: 6rpx;
display: flex;
align-items: center;
gap: 14rpx;
padding: 12rpx 14rpx 12rpx 28rpx;
border-radius: 52rpx;
background: linear-gradient(180deg, rgba(43, 19, 83, 0.72), rgba(32, 14, 61, 0.66));
border: 1rpx solid rgba(168, 85, 247, 0.42);
box-shadow: 0 0 22rpx rgba(116, 52, 202, 0.12);
transition: min-height 0.22s ease, border-radius 0.22s ease, border-color 0.22s ease, box-shadow 0.22s ease;
}
.wish-input-wrap.active {
border-color: rgba(209, 138, 255, 0.62);
box-shadow: 0 0 28rpx rgba(168, 85, 247, 0.18);
}
.wish-input-wrap.twoLine {
min-height: 126rpx;
align-items: flex-end;
border-radius: 42rpx;
}
.wish-input-wrap.expanded {
min-height: 158rpx;
align-items: flex-end;
border-radius: 36rpx;
}
.custom-input-placeholder {
position: absolute;
z-index: 1;
color: rgba(216, 180, 254, 0.48);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.home-input-placeholder {
left: 28rpx;
right: 138rpx;
top: 50%;
transform: translateY(-50%);
font-size: 34rpx;
line-height: 1;
}
.wish-input {
position: relative;
z-index: 2;
flex: 1;
height: 72rpx;
min-height: 72rpx;
max-height: 150rpx;
padding: 0;
box-sizing: border-box;
color: #fff;
font-size: 34rpx;
line-height: 72rpx;
}
.wish-input-wrap.twoLine .wish-input,
.wish-input-wrap.expanded .wish-input {
height: auto;
padding: 8rpx 0;
line-height: 48rpx;
}
.send-button {
position: relative;
z-index: 2;
min-width: 104rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999rpx;
color: #fff;
font-size: 28rpx;
font-weight: 700;
background: linear-gradient(145deg, #934dff, #4d1ccb);
}
.send-button.disabled {
opacity: 0.45;
}
.inspiration-section {
display: flex;
flex-direction: column;
gap: 24rpx;
margin-top: 4rpx;
margin-bottom: 8rpx;
}
.section-line {
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title {
font-size: 30rpx;
font-weight: 800;
}
.refresh {
font-size: 30rpx;
font-weight: 800;
color: #e8ccff;
}
.recommend-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 22rpx 24rpx;
}
.recommend-card {
min-height: 68rpx;
display: flex;
align-items: center;
min-width: 0;
padding: 0 22rpx;
border-radius: 24rpx;
background: linear-gradient(180deg, rgba(48, 24, 89, 0.78), rgba(32, 14, 62, 0.76));
border: 1rpx solid rgba(168, 85, 247, 0.22);
box-sizing: border-box;
}
.recommend-text {
width: 100%;
min-width: 0;
font-size: 26rpx;
line-height: 68rpx;
color: rgba(255, 255, 255, 0.92);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.generation-view {
flex: 1;
height: 100%;
min-height: 0;
padding: 0;
}
.generation-scroll {
width: 100%;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.generation-scroll-content {
min-height: 100%;
display: flex;
flex-direction: column;
gap: 42rpx;
box-sizing: border-box;
padding: 40rpx 0 190rpx;
}
.generation-scroll-anchor {
width: 1rpx;
height: 28rpx;
}
.conversation {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.conversation.compact {
margin-bottom: 26rpx;
}
.chat-bubble {
max-width: 86%;
display: flex;
flex-direction: column;
gap: 8rpx;
padding: 24rpx 28rpx;
border-radius: 36rpx;
font-size: 34rpx;
line-height: 52rpx;
}
.chat-bubble.user {
align-self: flex-end;
background: linear-gradient(145deg, rgba(140, 68, 242, 0.72), rgba(95, 29, 184, 0.64));
color: #fff;
border: 1rpx solid rgba(209, 138, 255, 0.16);
backdrop-filter: blur(10rpx);
-webkit-backdrop-filter: blur(10rpx);
}
.chat-bubble.system {
align-self: flex-start;
background: rgba(16, 8, 34, 0.28);
border: 1rpx solid rgba(192, 132, 252, 0.3);
color: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8rpx);
-webkit-backdrop-filter: blur(8rpx);
}
.chat-bubble.pending {
border-color: rgba(255, 216, 107, 0.28);
}
.message-toggle {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 18rpx;
border-radius: 999rpx;
color: #ffd86b;
font-size: 24rpx;
line-height: 32rpx;
font-weight: 700;
background: rgba(140, 68, 242, 0.18);
border: 1rpx solid rgba(255, 216, 107, 0.18);
}
.message-toggle-icon {
font-size: 22rpx;
line-height: 28rpx;
transition: transform 0.2s ease;
}
.message-toggle-icon.open {
transform: rotate(180deg);
}
.thinking-dots {
display: flex;
align-items: center;
gap: 10rpx;
padding-top: 8rpx;
}
.thinking-dots view {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #ffd86b;
box-shadow: 0 0 18rpx rgba(255, 216, 107, 0.52);
animation: thinkingDot 1.2s ease-in-out infinite;
}
.thinking-dots view:nth-child(2) {
animation-delay: 0.18s;
background: #d18aff;
}
.thinking-dots view:nth-child(3) {
animation-delay: 0.36s;
background: #8c44f2;
}
@keyframes thinkingDot {
0%, 80%, 100% {
opacity: 0.38;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-8rpx);
}
}
.stream-preview {
display: block;
color: rgba(255, 255, 255, 0.86);
font-size: 28rpx;
line-height: 44rpx;
white-space: pre-wrap;
word-break: break-all;
}
.stream-preview.typing {
padding-top: 12rpx;
}
.typing-cursor {
color: #ffd86b;
animation: cursorBlink 0.9s steps(2, start) infinite;
}
.generation-error {
display: block;
color: rgba(255, 231, 163, 0.92);
font-size: 28rpx;
line-height: 44rpx;
white-space: pre-wrap;
}
@keyframes cursorBlink {
0%, 45% {
opacity: 1;
}
46%, 100% {
opacity: 0;
}
}
.chat-bubble.done {
border-color: rgba(192, 132, 252, 0.42);
}
.bubble-time {
font-size: 24rpx;
line-height: 32rpx;
color: rgba(255, 255, 255, 0.65);
}
.loading-orbit {
position: relative;
width: 180rpx;
height: 180rpx;
align-self: center;
border-radius: 50%;
border: 3rpx solid rgba(192, 132, 252, 0.28);
box-shadow: 0 0 44rpx rgba(168, 85, 247, 0.55);
animation: orbitBreath 2.4s ease-in-out infinite;
transition: opacity 0.2s ease, filter 0.2s ease;
}
.generation-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 28rpx;
}
.loading-orbit.streaming {
filter: saturate(1.2);
}
.loading-orbit.failed {
opacity: 0.52;
filter: grayscale(0.2);
}
.loading-orbit::after {
content: '';
position: absolute;
right: 18rpx;
top: 20rpx;
width: 24rpx;
height: 24rpx;
border-radius: 50%;
background: #ffd86b;
box-shadow: 0 0 24rpx rgba(255, 216, 107, 0.72);
animation: orbitLight 1.8s linear infinite;
transform-origin: -72rpx 70rpx;
}
.orbit-ring {
position: absolute;
inset: -22rpx;
border-radius: 50%;
border: 1rpx solid rgba(255, 216, 107, 0.16);
animation: ringPulse 2.8s ease-in-out infinite;
}
.orbit-ring.inner {
inset: 18rpx;
border-color: rgba(209, 138, 255, 0.2);
animation-delay: 0.7s;
}
.orbit-core {
position: absolute;
inset: 42rpx;
border-radius: 50%;
background: radial-gradient(circle, #c084fc, #5c1bb0);
box-shadow: inset 0 0 22rpx rgba(255, 255, 255, 0.18);
}
@keyframes orbitBreath {
0%, 100% {
transform: scale(0.96);
box-shadow: 0 0 36rpx rgba(168, 85, 247, 0.42);
}
50% {
transform: scale(1.04);
box-shadow: 0 0 70rpx rgba(168, 85, 247, 0.66);
}
}
@keyframes orbitLight {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes ringPulse {
0%, 100% {
opacity: 0.2;
transform: scale(0.92);
}
50% {
opacity: 0.78;
transform: scale(1.08);
}
}
.loading-copy {
text-align: center;
font-size: 30rpx;
color: rgba(255, 255, 255, 0.75);
}
.loading-subcopy {
display: block;
max-width: 620rpx;
text-align: center;
padding: 18rpx 24rpx;
border-radius: 28rpx;
color: rgba(255, 231, 163, 0.78);
font-size: 24rpx;
line-height: 36rpx;
background: rgba(255, 216, 107, 0.07);
border: 1rpx solid rgba(255, 216, 107, 0.14);
}
.generation-actions {
display: flex;
justify-content: center;
gap: 18rpx;
}
.generation-action {
min-width: 176rpx;
height: 72rpx;
margin: 0;
padding: 0 28rpx;
border-radius: 999rpx;
font-size: 27rpx;
line-height: 72rpx;
}
.generation-action::after {
border: 0;
}
.generation-action.primary {
color: #1b0b31;
background: linear-gradient(145deg, #ffd86b, #d18aff);
box-shadow: 0 16rpx 36rpx rgba(209, 138, 255, 0.24);
}
.generation-action.secondary {
color: #e8ccff;
background: rgba(43, 19, 83, 0.68);
border: 1rpx solid rgba(168, 85, 247, 0.32);
}
.story-card {
border-radius: 52rpx;
padding: 34rpx;
background: rgba(12, 5, 28, 0.34);
border: 1rpx solid rgba(192, 132, 252, 0.46);
box-shadow: 0 0 48rpx rgba(125, 55, 205, 0.14);
backdrop-filter: blur(6rpx);
-webkit-backdrop-filter: blur(6rpx);
}
.story-card.collapsed {
box-shadow: 0 0 42rpx rgba(125, 55, 205, 0.14);
}
.story-head {
display: flex;
align-items: flex-start;
gap: 20rpx;
}
.story-head-actions {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12rpx;
}
.story-title-wrap {
flex: 1;
min-width: 0;
}
.story-title {
display: block;
font-size: 52rpx;
font-weight: 700;
line-height: 1.35;
}
.collapse-icon {
width: 120rpx;
min-width: 120rpx;
height: 58rpx;
min-height: 58rpx;
line-height: 1;
padding: 0 18rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
color: rgba(255, 244, 255, 0.95);
background:
linear-gradient(135deg, rgba(134, 72, 255, 0.44), rgba(39, 15, 76, 0.42)),
rgba(255, 255, 255, 0.05);
border: 1rpx solid rgba(216, 180, 254, 0.46);
box-shadow:
0 14rpx 30rpx rgba(61, 18, 113, 0.22),
inset 0 1rpx 0 rgba(255, 255, 255, 0.26),
inset 0 -10rpx 18rpx rgba(33, 9, 73, 0.16);
backdrop-filter: blur(8rpx);
-webkit-backdrop-filter: blur(8rpx);
font-size: 23rpx;
font-weight: 800;
letter-spacing: 0;
}
.collapse-icon::after {
border: 0;
}
.collapse-icon:active,
.copy-card-btn:active,
.collapse-row:active {
transform: scale(0.98);
opacity: 0.92;
}
.copy-card-btn {
width: 120rpx;
min-width: 120rpx;
height: 50rpx;
min-height: 50rpx;
line-height: 1;
padding: 0;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 244, 255, 0.9);
font-size: 23rpx;
font-weight: 800;
letter-spacing: 0;
background:
radial-gradient(circle at 50% -30%, rgba(255, 255, 255, 0.18), transparent 50%),
rgba(88, 28, 135, 0.26);
border: 1rpx solid rgba(216, 180, 254, 0.32);
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.16),
0 10rpx 22rpx rgba(61, 18, 113, 0.12);
backdrop-filter: blur(8rpx);
-webkit-backdrop-filter: blur(8rpx);
}
.copy-card-btn::after {
border: 0;
}
.collapse-icon-text,
.collapse-row-text {
line-height: 1;
white-space: nowrap;
}
.collapse-chevron {
position: relative;
width: 20rpx;
height: 16rpx;
flex-shrink: 0;
transition: transform 0.18s ease;
}
.collapse-chevron.down {
transform: rotate(180deg);
}
.collapse-chevron view {
position: absolute;
top: 7rpx;
width: 12rpx;
height: 4rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #fff3b0, #ffd86b);
box-shadow: 0 0 12rpx rgba(255, 216, 107, 0.5);
}
.collapse-chevron view:first-child {
left: 0;
transform: rotate(-38deg);
transform-origin: right center;
}
.collapse-chevron view:last-child {
right: 0;
transform: rotate(38deg);
transform-origin: left center;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.tag {
padding: 6rpx 16rpx;
border-radius: 999rpx;
color: #d18aff;
font-size: 24rpx;
background: rgba(168, 85, 247, 0.22);
}
.story-body {
display: block;
margin-top: 28rpx;
font-size: 32rpx;
font-weight: 400;
line-height: 1.78;
color: rgba(255, 255, 255, 0.92);
white-space: pre-wrap;
}
.story-body-scroll {
max-height: 420rpx;
height: auto;
margin-top: 28rpx;
padding-right: 8rpx;
box-sizing: border-box;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.story-body-scroll .story-body {
margin-top: 0;
}
.collapse-row {
position: relative;
height: 62rpx;
margin-top: 22rpx;
padding: 0 28rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
border-radius: 999rpx;
color: rgba(246, 230, 255, 0.96);
font-size: 26rpx;
font-weight: 800;
overflow: hidden;
background:
radial-gradient(circle at 50% -20%, rgba(255, 255, 255, 0.2), transparent 46%),
linear-gradient(135deg, rgba(98, 40, 174, 0.3), rgba(24, 8, 58, 0.24));
border: 1rpx solid rgba(192, 132, 252, 0.36);
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.16),
0 12rpx 28rpx rgba(61, 18, 113, 0.14);
backdrop-filter: blur(6rpx);
-webkit-backdrop-filter: blur(6rpx);
}
.collapse-row::before {
content: '';
position: absolute;
left: 34rpx;
right: 34rpx;
top: 0;
height: 1rpx;
background: linear-gradient(90deg, transparent, rgba(255, 236, 180, 0.6), transparent);
}
.result-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 26rpx;
}
.action-btn {
height: 72rpx;
min-height: 72rpx;
padding: 0 8rpx;
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
color: #e8ccff;
font-size: 26rpx;
font-weight: 700;
line-height: 1.15;
text-align: center;
white-space: normal;
box-sizing: border-box;
background: rgba(88, 28, 135, 0.18);
border: 1rpx solid rgba(192, 132, 252, 0.35);
}
.action-btn.primary {
color: #fff;
background: linear-gradient(145deg, #8c44f2, #5f1db8);
}
.action-icon {
margin-right: 8rpx;
font-size: 24rpx;
font-weight: 900;
line-height: 1;
}
.action-btn::after {
border: 0;
}
.result-chat-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 24rpx;
}
.result-scroll-anchor {
width: 1rpx;
height: 28rpx;
}
.scroll-jump-group {
position: absolute;
right: 22rpx;
bottom: 258rpx;
z-index: 13;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.scroll-jump-btn {
width: 86rpx;
height: 58rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999rpx;
color: #e8ccff;
font-size: 23rpx;
font-weight: 900;
background: rgba(43, 19, 83, 0.72);
border: 1rpx solid rgba(192, 132, 252, 0.34);
box-shadow: 0 12rpx 34rpx rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18rpx);
-webkit-backdrop-filter: blur(18rpx);
}
.result-chat-bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 12;
min-height: 104rpx;
display: flex;
align-items: flex-end;
gap: 12rpx;
padding: 14rpx 0 22rpx;
box-sizing: border-box;
background: linear-gradient(180deg, rgba(5, 2, 13, 0), rgba(5, 2, 13, 0.9) 30%, rgba(5, 2, 13, 0.96));
}
.chat-voice-btn {
width: 86rpx;
height: 76rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 26rpx;
color: #e8ccff;
font-size: 24rpx;
font-weight: 800;
background: rgba(88, 28, 135, 0.32);
border: 1rpx solid rgba(192, 132, 252, 0.3);
}
.chat-voice-btn.pressing,
.chat-voice-btn.recognizing {
color: #fff;
background: linear-gradient(145deg, #934dff, #4d1ccb);
box-shadow: 0 0 30rpx rgba(168, 85, 247, 0.38);
}
.result-input-shell {
position: relative;
flex: 1;
height: 76rpx;
min-height: 76rpx;
max-height: 146rpx;
box-sizing: border-box;
border-radius: 28rpx;
background: rgba(43, 19, 83, 0.72);
border: 1rpx solid rgba(168, 85, 247, 0.36);
overflow: hidden;
}
.result-input-shell.twoLine {
height: auto;
min-height: 110rpx;
border-radius: 30rpx;
}
.result-input-shell.expanded {
height: auto;
min-height: 144rpx;
border-radius: 30rpx;
}
.result-input-placeholder {
left: 22rpx;
right: 22rpx;
top: 50%;
transform: translateY(-50%);
font-size: 28rpx;
line-height: 1;
}
.result-chat-input {
position: relative;
z-index: 2;
width: 100%;
height: 76rpx;
min-height: 76rpx;
max-height: 146rpx;
padding: 0 22rpx;
box-sizing: border-box;
color: #fff;
font-size: 28rpx;
line-height: 76rpx;
background: transparent;
}
.result-input-shell.twoLine .result-chat-input,
.result-input-shell.expanded .result-chat-input {
height: auto;
min-height: 78rpx;
padding: 16rpx 22rpx;
line-height: 40rpx;
}
.chat-send-btn {
width: 92rpx;
height: 76rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 26rpx;
color: #fff;
font-size: 26rpx;
font-weight: 800;
background: linear-gradient(145deg, #934dff, #4d1ccb);
}
.chat-send-btn.disabled {
opacity: 0.45;
}
</style>