Files
happy-life-star/mini-program/src/pages/main/ScriptView.vue
T
peanut 7d19fa5898 fix:澄清卡片显示完整选项和中文答案
问题:详情页 chat-bubble 卡片视图显示「已回答:family」等英文 value,
而不是中文 label(如「家人」),且不展示所有可选选项。

修复:
- 新增 getAnswerLabel(msg) 函数,通过 card.options 查找 value 对应的中文 label
- rebuildResultMessages 增加 selectedValue 字段,与 answer 同时存原始 value
- 卡片已回答视图新增 card-options-list,渲染所有选项,已选加 .selected 样式并标 ✓
- submitClarification(实时模式)同步存 selectedValue,保持渲染逻辑一致

影响:
- ScriptView.vue(chat-bubble 流程视图)
- ScriptDetailView.vue(详情页只读视图)

数据基础未动,后端澄清问答 answers 仍存原始 value(语义化)。
全局约束:rpx 单位、代码注释中文、禁止 mock 数据。
2026-07-26 16:49:44 +08:00

4041 lines
110 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 === 'chat'" class="chat-page">
<view class="chat-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="chat-scroll"
scroll-y
:scroll-top="resultCommandScrollTop"
:scroll-into-view="resultScrollTarget"
:scroll-with-animation="true"
:enhanced="true"
:show-scrollbar="false"
@scroll="handleResultScroll"
>
<view class="chat-scroll-content">
<view id="result-scroll-top" class="result-scroll-top"></view>
<view class="conversation compact">
<view
v-for="msg in resultMessages"
:key="msg.id"
>
<view v-if="msg.role === 'user' && msg.kind === 'text'" class="chat-bubble user">
<text>{{ msg.content }}</text>
<text class="bubble-time">{{ msg.time }}</text>
</view>
<view v-else-if="msg.kind === 'card'" class="chat-bubble system">
<ClarificationCard
v-if="!msg.submitted && msg.card"
:card="msg.card"
@submit="submitClarification"
/>
<view v-else class="card-answered">
<text v-if="msg.card?.question" class="card-question-text">{{ msg.card.question }}</text>
<!-- 用户回答中文 label通过 options value 映射回 label 显示 -->
<view class="card-answer-row">
<text class="card-answer-prefix">已回答</text>
<text class="card-answer-label">{{ getAnswerLabel(msg) }}</text>
</view>
<!-- 选项列表展示用户可选的所有选项已选的高亮显示 -->
<view v-if="Array.isArray(msg.card?.options) && msg.card.options.length" class="card-options-list">
<view
v-for="opt in msg.card.options"
:key="opt.value"
class="card-option-item"
:class="{ selected: opt.value === (msg.selectedValue || msg.answer) }"
>
<text class="card-option-label">{{ opt.label || opt.value }}</text>
<text v-if="opt.description" class="card-option-desc">{{ opt.description }}</text>
<text v-if="opt.value === (msg.selectedValue || msg.answer)" class="card-option-mark"></text>
</view>
</view>
</view>
</view>
<view v-else-if="msg.kind === 'outline'" class="chat-bubble system">
<view v-if="msg.outline">
<view v-if="msg.outline.title" class="outline-title">
<text>{{ msg.outline.title }}</text>
</view>
<view v-if="msg.outline.logline" class="outline-logline">
<text>{{ msg.outline.logline }}</text>
</view>
<view v-if="Array.isArray(msg.outline.beats)" class="outline-beats">
<view v-for="(beat, idx) in msg.outline.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="msg.outline.ending" class="outline-ending">
<text class="ending-label">结局</text>
<text class="ending-text">{{ msg.outline.ending }}</text>
</view>
</view>
<view v-if="!msg.confirmed" class="outline-actions">
<textarea v-model="msg.feedback" placeholder="如需修改请输入意见" class="outline-feedback" auto-height />
<view class="outline-buttons">
<view class="btn-secondary" @click="modifyOutline(msg)">修改大纲</view>
<view class="btn-primary" @click="confirmOutline(msg)">确认大纲</view>
</view>
</view>
</view>
<view v-else-if="msg.kind === 'novel'" class="chat-bubble system">
<text class="novel-text">{{ msg.content }}<text v-if="msg.pending" class="typing-cursor">|</text></text>
</view>
<view v-else class="chat-bubble system">
<text :class="{ 'generation-error': msg.failed }">{{ msg.content }}</text>
<text class="bubble-time">{{ msg.time }}</text>
</view>
</view>
<view v-if="showChatLoading" class="chat-loading">
<view class="loading-orbit" :class="{ streaming: generationStatus === 'streaming' }">
<view class="orbit-ring outer"></view>
<view class="orbit-ring inner"></view>
<view class="orbit-core"></view>
</view>
<text class="loading-copy">{{ generationCopy }}</text>
</view>
<view v-if="generationStatus === 'failed' && resumeableSessionId" class="resume-action">
<button class="generation-action primary" @click="resumeSession">继续创作</button>
</view>
<view :id="resultScrollAnchor" class="result-scroll-anchor"></view>
</view>
</view>
</scroll-view>
<view v-if="generationPhase === 'done'" class="chat-input-bar">
<view class="result-input-shell">
<textarea
class="result-chat-input"
v-model="chatInput"
:auto-height="true"
:show-confirm-bar="false"
maxlength="500"
confirm-type="send"
placeholder="输入修改建议,或让 AI 继续生成"
@confirm="sendChat"
/>
</view>
<view class="chat-send-btn" :class="{ disabled: !chatInput.trim() || generating }" @click="sendChat">发送</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'
import MessageCard from '../../components/MessageCard.vue'
import ClarificationCard from '../../components/ClarificationCard.vue'
import {
startNovelStream,
followupStream
} from '../../services/shortNovel.js'
import {
listMessagesByConversation,
listMessageVersions,
switchVersion,
deleteVersion
} from '../../services/scriptChat.js'
import { createMessage } from '../../services/message.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 resumeableSessionId = ref('')
const generationPhase = ref('idle')
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 viewMode = ref('chat')
const scriptId = ref('')
const conversationId = ref('')
const currentVersionMessageId = ref('')
// 短篇小说 SSE 流式生成新增字段
const novelSessionId = ref('')
const firstQuery = ref('') // 首次发送的原始心愿,用于保存剧本时记录
const novelFullText = ref('')
const novelOutline = ref(null)
const clarificationCard = ref(null)
const currentStreamTask = ref(null)
const answeringClarification = ref(false)
// 等待下一个 assistant 响应(包括 followupStream 的所有响应)
const pendingNextResponse = ref(false)
const outlineFeedback = ref('')
const messages = ref([])
const versions = ref([])
const chatInput = ref('')
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)
analytics.track('script_message_collapse_toggle', {
message_id: message?.id || '',
collapsed: collapsedMessageIds.value[message.id]
}, { eventType: 'script', pagePath })
}
const copyMessageContent = (message) => {
const content = String(message?.content || '')
if (!content.trim()) {
uni.showToast({ title: '暂无可复制内容', icon: 'none' })
return
}
uni.setClipboardData({
data: content,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
}
})
analytics.track('script_message_copy_click', {
message_id: message?.id || '',
content_length: content.length
}, { eventType: 'script', pagePath })
}
const playMessageTts = (message) => {
const scriptId = currentResult.value?.id || ''
ttsPlayer.playSource(scriptId)
analytics.track('script_message_tts_click', {
message_id: message?.id || '',
script_id: scriptId
}, { eventType: 'script', pagePath })
}
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 currentScriptContent = computed(() => {
const targetId = currentVersionMessageId.value
const targetMessage = messages.value.find(m => m.id === targetId && m.type === 'script')
let result
if (!targetMessage || !targetMessage.metadata) {
result = {
title: currentResult.value?.title || '',
plotIntro: currentResult.value?.plotIntro || '',
plotTurning: currentResult.value?.plotTurning || '',
plotClimax: currentResult.value?.plotClimax || '',
plotEnding: currentResult.value?.plotEnding || ''
}
} else {
try {
result = JSON.parse(targetMessage.metadata)
} catch (e) {
result = { title: '', plotIntro: targetMessage.metadata || '' }
}
}
// 章节字段全空时,用 plotJson.fullContent 作为 fallback
const hasChapter = result.plotIntro || result.plotTurning || result.plotClimax || result.plotEnding
if (!hasChapter) {
const fullContent = currentResult.value?.plotJson?.fullContent || ''
if (fullContent) {
result.plotIntro = fullContent
}
}
return result
})
const displayMessages = computed(() => messages.value)
const loadMessages = async () => {
if (!conversationId.value) return
try {
const res = await listMessagesByConversation({ conversationId: conversationId.value, includeVersions: false })
let loaded = res.data || []
// 历史剧本兼容:若 API 返回空且 plotJson 有内容,自动初始化首条 AI 消息
if (loaded.length === 0 && currentResult.value?.plotJson?.fullContent) {
await initializeFirstAIMessage()
const retryRes = await listMessagesByConversation({ conversationId: conversationId.value, includeVersions: false })
loaded = retryRes.data || []
}
messages.value = loaded
const scriptMessages = messages.value.filter(m => m.type === 'script')
if (scriptMessages.length > 0) {
const rootMessage = scriptMessages[0]
const versionRes = await listMessageVersions(rootMessage.id)
versions.value = versionRes.data || []
}
} catch (error) {
uni.showToast({ title: error.message || '加载消息失败', icon: 'none' })
}
}
const getCurrentUserId = () => {
try {
const token = uni.getStorageSync('access_token')
if (!token) return ''
const payload = token.split('.')[1]
const decoded = JSON.parse(decodeURIComponent(Array.from(atob(payload), c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')))
return decoded.sub || ''
} catch (e) {
return ''
}
}
/**
* 历史剧本兼容:基于 plotJson 初始化首条 AI 剧本消息
* 触发条件:loadMessages 返回空列表,且 currentResult.plotJson.fullContent 存在
*/
const initializeFirstAIMessage = async () => {
const content = currentResult.value?.plotJson?.fullContent || ''
if (!content) return
try {
const userId = getCurrentUserId()
// 先保存用户心愿消息(theme)
const theme = currentResult.value?.theme || currentResult.value?.title || ''
if (theme) {
await createMessage({
conversationId: conversationId.value,
userId,
content: theme,
senderType: 'user',
contentType: 'chat'
})
}
// 再保存 AI 剧本消息
await createMessage({
conversationId: conversationId.value,
userId,
content,
senderType: 'assistant',
contentType: 'script',
versionNumber: 1
})
} catch (error) {
// 初始化失败不阻塞页面加载,仅日志
console.warn('[ScriptView] initializeFirstAIMessage failed:', error)
}
}
/**
* 格式化版本标签,显示在 MessageCard 上
*/
const formatVersionLabel = (message) => {
if (message.type !== 'script') return ''
const versionNum = message.versionNumber || 1
const isCurrent = message.id === currentVersionMessageId.value
return isCurrent ? `V${versionNum} (当前)` : `V${versionNum}`
}
/**
* 判断消息是否有子版本
*/
const messageHasChildren = (message) => {
if (!message || message.type !== 'script') return false
// 通过 versions 列表判断是否有子版本
return versions.value.some(v => v.parentMessageId === message.id)
}
/**
* 查看消息的版本历史
*/
const viewMessageVersions = async (message) => {
try {
const res = await listMessageVersions(message.id)
const versionList = res.data || []
if (versionList.length === 0) {
uni.showToast({ title: '暂无历史版本', icon: 'none' })
return
}
const items = versionList.map(v => {
const isCurrent = v.id === currentVersionMessageId.value
return `${isCurrent ? '★ ' : ''}V${v.versionNumber || '-'}`
})
uni.showActionSheet({
itemList: items,
success: async (actionRes) => {
const selected = versionList[actionRes.tapIndex]
if (!selected) return
if (selected.id === currentVersionMessageId.value) {
uni.showToast({ title: '已是当前版本', icon: 'none' })
return
}
try {
await switchVersion({ scriptId: scriptId.value, messageId: selected.id })
currentVersionMessageId.value = selected.id
await loadMessages()
uni.showToast({ title: `已切换到 V${selected.versionNumber}`, icon: 'success' })
} catch (switchErr) {
uni.showToast({ title: switchErr.message || '切换失败', icon: 'none' })
}
}
})
} catch (error) {
uni.showToast({ title: error.message || '加载版本失败', icon: 'none' })
}
}
const sendChat = async () => {
if (!chatInput.value.trim() || generating.value) return
const content = chatInput.value.trim()
chatInput.value = ''
// 仅在 generationPhase === 'done' 时处理;生成流程中底部输入栏不显示,但防御性 short-circuit
if (generationPhase.value !== 'done') return
const messageId = currentVersionMessageId.value
const userMessageRes = await createMessage({
conversationId: conversationId.value,
userId: getCurrentUserId(),
content,
senderType: 'user',
contentType: 'chat'
})
const userMessageId = userMessageRes.data?.id
// 用户消息追加到统一对话流 resultMessages
resultMessages.value.push({
id: userMessageId || createMessageId(),
role: 'user',
kind: 'text',
content,
pending: false,
time: formatMessageTime(),
submitted: false,
confirmed: false
})
keepResultAtBottom()
generating.value = true
generationStatus.value = 'streaming'
await streamScriptChat({
operationType: 'chat',
conversationId: conversationId.value,
messageId,
userMessageId,
content,
scriptId: scriptId.value,
onDelta: (delta) => {
// 往最后一条 AI 小说消息追加 delta
const lastAssistant = [...resultMessages.value].reverse().find(m => m.role === 'assistant' && m.kind === 'novel')
if (lastAssistant) {
lastAssistant.content += delta
}
},
onDone: () => {
generating.value = false
generationStatus.value = 'idle'
loadMessages()
},
onError: (error) => {
generating.value = false
generationStatus.value = 'idle'
uni.showToast({ title: error || '生成失败', icon: 'none' })
}
})
}
const rewriteMessage = (msg) => {
chatInput.value = ''
uni.showModal({
title: '改写意图',
editable: true,
placeholderText: '例如:把结局改得更积极',
success: async (res) => {
if (res.confirm && res.content) {
await sendRewrite(msg.id, res.content)
}
}
})
}
const sendRewrite = async (targetMessageId, content) => {
const userMessageRes = await createMessage({
conversationId: conversationId.value,
userId: getCurrentUserId(),
content,
senderType: 'user',
contentType: 'chat'
})
const userMessageId = userMessageRes.data?.id
messages.value.push({
id: userMessageId,
conversationId: conversationId.value,
content,
sender: 'user',
type: 'chat'
})
generating.value = true
await streamScriptChat({
operationType: 'rewrite',
conversationId: conversationId.value,
messageId: targetMessageId,
userMessageId,
content,
scriptId: scriptId.value,
onDelta: () => {},
onDone: () => {
generating.value = false
loadMessages()
},
onError: (error) => {
generating.value = false
uni.showToast({ title: error || '改写失败', icon: 'none' })
}
})
}
const continueMessage = (msg) => {
chatInput.value = '继续生成后续内容'
sendChatAsContinue(msg.id)
}
const sendChatAsContinue = async (targetMessageId) => {
const content = '继续生成后续内容'
const userMessageRes = await createMessage({
conversationId: conversationId.value,
content,
sender: 'user',
type: 'chat'
})
const userMessageId = userMessageRes.data?.id
generating.value = true
await streamScriptChat({
operationType: 'continue',
conversationId: conversationId.value,
messageId: targetMessageId,
userMessageId,
content,
scriptId: scriptId.value,
onDelta: () => {},
onDone: () => {
generating.value = false
loadMessages()
},
onError: (error) => {
generating.value = false
uni.showToast({ title: error || '续写失败', icon: 'none' })
}
})
}
const showVersions = async (msg) => {
const res = await listMessageVersions(msg.id)
const list = res.data || []
const items = list.map(v => `V${v.versionNumber} ${v.id === currentVersionMessageId.value ? '(当前)' : ''}`)
uni.showActionSheet({
itemList: items,
success: async (res) => {
const selected = list[res.tapIndex]
// 切换到选中的版本
try {
await switchVersion({ scriptId: scriptId.value, messageId: selected.id })
currentVersionMessageId.value = selected.id
await loadMessages()
uni.showToast({ title: `已切换到 V${selected.versionNumber}`, icon: 'success' })
} catch (error) {
uni.showToast({ title: error.message || '切换失败', icon: 'none' })
}
}
})
}
const removeVersion = async (msg) => {
if (msg.id === currentVersionMessageId.value) {
uni.showToast({ title: '不能删除当前生效版本', icon: 'none' })
return
}
uni.showModal({
title: '确认删除',
content: `确定删除 V${msg.versionNumber} 吗?`,
success: async (res) => {
if (res.confirm) {
await deleteVersion(msg.id)
await loadMessages()
}
}
})
}
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 = () => {
// 统一对话页:viewState 只有 home/chatresult 已合并到 chat
if (viewState.value !== 'chat') 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 !== 'chat') 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 !== 'chat') 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 '正在把你的心愿写成故事'
})
// chat 视图 loading 显示条件:等待首个 assistant 响应期间
// - generationPhase === 'generating':生成流程未完成
// - generationStatus !== 'failed':非失败状态
// - pendingNextResponse:用户刚发完请求(心愿或澄清答案),等待下一个 assistant 响应
const showChatLoading = computed(() => {
if (generationPhase.value !== 'generating') return false
if (generationStatus.value === 'failed') return false
return pendingNextResponse.value === true
})
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) => {
// 统一对话页:在 chat 视图填充底部输入栏,在 home 视图填充心愿输入框
if (viewState.value === 'chat') {
chatInput.value = text
} 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 === 'chat' ? '识别成功,可继续修改建议' : '识别成功,可修改后发送', 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
try {
recorderManager = uni.getRecorderManager()
if (!recorderManager) return
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', '录音失败,请检查麦克风权限')
})
} catch {
recorderManager = null
}
}
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)}`
/**
* 把已回答卡片的 value(如 "family")映射为对应的中文 label(如 "家人"
* 若选项中找不到匹配的 value,则原样返回 answer(兜底)
* 若 msg.card 不存在则返回 "未回答"
*/
const getAnswerLabel = (msg) => {
if (!msg || !msg.card) return '未回答'
const raw = msg.selectedValue || msg.answer || ''
if (!raw) return '未回答'
const options = Array.isArray(msg.card.options) ? msg.card.options : []
const match = options.find(opt => opt && opt.value === raw)
return match?.label || raw
}
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) => {
// localStorage 不再作为主要消息存储
return []
}
const readScriptChatHistory = ({ conversationId, scriptId } = {}) => {
// localStorage 不再作为主要消息存储
return []
}
const persistResultMessages = () => {
// localStorage 不再作为主要消息存储,消息通过后端 API 管理
}
const hydrateResultMessages = ({ conversationId, scriptId } = {}) => {
// localStorage 不再作为主要消息存储,消息通过后端 API 管理
resultMessages.value = []
collapsedMessageIds.value = {}
}
const addResultMessage = ({ role, content, pending = false, kind = 'text', card = null, outline = null, answer = '', feedback = '', failed = false }) => {
const message = {
id: createMessageId(),
role,
kind,
content,
pending,
time: formatMessageTime(),
card,
outline,
answer,
feedback,
failed,
submitted: false,
confirmed: false
}
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 scriptIdParam = String(payload?.id || payload?.scriptId || '')
let script = payload?.script || (payload?.id ? payload : null)
if (!script?.id && scriptIdParam) {
script = store.getScriptById(scriptIdParam)
if (!script) {
await store.fetchScripts()
script = store.getScriptById(scriptIdParam)
}
}
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 })
scriptId.value = script.id
conversationId.value = script.conversationId || ''
currentVersionMessageId.value = script.currentVersionMessageId || ''
wishText.value = script.theme || script.prompt || script.title || '继续修改这个人生剧本'
currentMessageTime.value = formatMessageTime()
currentResultTime.value = script.updateTime || script.updatedAt || script.createTime || script.createdAt || formatMessageTime()
viewMode.value = 'chat'
// 切换到统一对话页(result 视图已合并到 chat
viewState.value = 'chat'
generationPhase.value = 'done'
// 清空旧的统一对话消息,准备从历史记录重建
resultMessages.value = []
if (conversationId.value) {
await loadMessages()
}
// 从后端 messages 重建统一对话消息流,复刻生成时的 chat-bubble 展示
// 不再重复 push wishTheme/novelContentmessages 中已包含)
const messagesList = messages.value || []
messagesList.forEach(m => {
if (m.type === 'clarification_question') {
let card = {}
try { card = JSON.parse(m.content || '{}') } catch (e) { card = {} }
resultMessages.value.push({
id: m.id || createMessageId(),
role: 'assistant',
kind: 'card',
content: '',
card,
answer: '',
pending: false,
time: formatMessageTime(),
submitted: true,
confirmed: true
})
} else if (m.type === 'clarification_answer') {
// 把答案合并到上一条 card 消息:answer 存用户原始 valueselectedValue 也存 value 用于渲染时映射 label
const lastCard = [...resultMessages.value].reverse().find(x => x.kind === 'card')
if (lastCard) {
const rawValue = m.content || ''
lastCard.answer = rawValue // 原始 value(用于查找 label
lastCard.selectedValue = rawValue // 选中的 value(渲染时通过 options 映射成 label
lastCard.submitted = true
} else {
// 无对应 question,作为独立 user 消息
resultMessages.value.push({
id: m.id || createMessageId(),
role: 'user',
kind: 'text',
content: m.content || '',
pending: false,
time: formatMessageTime(),
submitted: false,
confirmed: false
})
}
} else if (m.type === 'outline') {
let outline = {}
try { outline = JSON.parse(m.content || '{}') } catch (e) { outline = {} }
resultMessages.value.push({
id: m.id || createMessageId(),
role: 'assistant',
kind: 'outline',
content: '',
outline,
pending: false,
time: formatMessageTime(),
submitted: false,
confirmed: true
})
} else if (m.type === 'script') {
resultMessages.value.push({
id: m.id || createMessageId(),
role: 'assistant',
kind: 'novel',
content: m.content || '',
pending: false,
time: formatMessageTime(),
submitted: false,
confirmed: true
})
} else if (m.type === 'chat' && m.sender === 'user') {
resultMessages.value.push({
id: m.id || createMessageId(),
role: 'user',
kind: 'text',
content: m.content || '',
pending: false,
time: formatMessageTime(),
submitted: false,
confirmed: false
})
}
// 跳过 system 类型(不展示)
})
analytics.track('script_chat_open_from_library', {
script_id: script.id,
has_history: messagesList.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 }) => {
const text = String(prompt || '').trim()
const display = String(displayText || text).trim()
if (!text || generating.value) return
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 = ''
novelFullText.value = ''
novelOutline.value = null
clarificationCard.value = null
novelSessionId.value = ''
firstQuery.value = text // 记住首次心愿,用于后续 followup 保存剧本
console.log('[ScriptView] 设置 firstQuery:', { length: text.length })
// 把原始心愿保存到 store,供 followup 时透传给后端(避免 originalQuery 丢失导致不保存)
if (store.setSessionMeta) {
store.setSessionMeta({
sessionId: '', // 后续在 status 事件中更新
scriptId: '',
conversationId: '',
originalQuery: text,
style: 'career',
length: 'short'
})
}
pendingNextResponse.value = true // 标记等待下一个 assistant 响应
resumeableSessionId.value = ''
outlineFeedback.value = ''
generationDisplayText.value = display
generationScrollTarget.value = ''
generationScrollTop.value = 0
generationScrollAnchor.value = 'generation-stream-anchor-a'
generationAutoFollow.value = true
streamWriter.reset()
startGenerationFeedback()
ttsPlayer.reset()
viewState.value = 'chat'
generationPhase.value = 'generating'
resultMessages.value = []
addResultMessage({ role: 'user', kind: 'text', content: display })
keepResultAtBottom()
try {
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', {
source,
error: error.message || 'unknown'
}, { eventType: 'script', pagePath })
} finally {
generating.value = false
}
}
const handleShortNovelEvent = async (event) => {
const { type, session_id, payload = {} } = event
const timestamp = Date.now()
console.log('[ScriptView] 收到事件:', { type, session_id, timestamp })
if (session_id) {
novelSessionId.value = session_id
// 把外部 short-novel-service 返回的 session_id 同步到 store,供 followup 透传
if (store.setSessionMeta && store.getSessionMeta) {
const meta = store.getSessionMeta()
if (meta) {
meta.sessionId = session_id
store.setSessionMeta(meta)
}
}
}
switch (type) {
case 'status':
// 仅更新加载提示,不追加独立消息
generationStatus.value = payload.stage || 'processing'
break
case 'clarification_card':
addResultMessage({ role: 'assistant', kind: 'card', card: payload.card || null })
generationStatus.value = 'idle'
pendingNextResponse.value = false
break
case 'outline_created':
addResultMessage({ role: 'assistant', kind: 'outline', outline: payload.outline || null })
generationStatus.value = 'idle'
pendingNextResponse.value = false
break
case 'novel_start':
addResultMessage({ role: 'assistant', kind: 'novel', content: '', pending: true })
generationStatus.value = 'streaming'
pendingNextResponse.value = false
break
case 'novel_delta': {
// 往最后一条 pending novel 消息直接累加 delta,保证流式显示
const lastNovel = [...resultMessages.value].reverse().find(m => m.kind === 'novel' && m.pending)
if (lastNovel) {
const delta = payload.delta || ''
console.log('[ScriptView] novel_delta:', {
deltaLength: delta.length,
currentLength: lastNovel.content.length,
timestamp
})
lastNovel.content += delta
}
keepResultAtBottom()
break
}
case 'novel_done': {
// 标记最后一条 novel 消息完成
console.log('[ScriptView] novel_done:', {
scriptId: payload.scriptId,
hasFullText: !!payload.full_text,
timestamp
})
const lastNovel = [...resultMessages.value].reverse().find(m => m.kind === 'novel')
if (lastNovel) {
if (payload.full_text) lastNovel.content = payload.full_text
lastNovel.pending = false
}
scriptId.value = payload.scriptId || ''
conversationId.value = payload.conversationId || ''
currentVersionMessageId.value = payload.currentVersionMessageId || ''
generationPhase.value = 'done'
generationStatus.value = 'idle'
pendingNextResponse.value = false
persistResultMessages()
// 把 scriptId/conversationId 同步到 store,便于详情页/历史页查最新剧本
if (store.setSessionMeta && store.getSessionMeta) {
const meta = store.getSessionMeta()
if (meta) {
meta.scriptId = scriptId.value
meta.conversationId = conversationId.value
store.setSessionMeta(meta)
}
}
// 可靠刷新剧本列表:使用 await 确保后端返回后再继续,避免列表中看不到最新剧本
try {
await store.fetchScripts()
console.log('[ScriptView] novel_done 后剧本列表刷新成功')
} catch (e) {
console.error('[ScriptView] novel_done 后剧本列表刷新失败:', e)
uni.showToast({ title: '列表刷新失败,请手动刷新', icon: 'none' })
}
break
}
case 'error':
// 识别每日限制错误,保存 session_id 供"继续创作"使用
if (payload.code === 'DAILY_GENERATION_IN_PROGRESS' && session_id) {
resumeableSessionId.value = session_id
markGenerationFailed(payload.message || '今天已有未完成的创作,可点击继续创作恢复')
} else {
markGenerationFailed(payload.message || '生成失败')
addResultMessage({ role: 'assistant', kind: 'text', content: payload.message || '生成失败', failed: true })
}
break
}
}
const submitClarification = (answer) => {
if (answeringClarification.value) return
// 找最后一张未提交的卡片消息(mp-weixin 模板不支持内联箭头函数传 msg,改为自己查找)
const card = [...resultMessages.value].reverse().find(m => m.kind === 'card' && !m.submitted)
if (card) {
card.submitted = true
// answer 存用户提交的 value,渲染时通过 card.options 映射成中文 label(与历史回放逻辑一致)
card.answer = answer
card.selectedValue = answer
}
pendingNextResponse.value = true // 标记等待下一个 assistant 响应
answeringClarification.value = true
// originalQuery 兜底:优先 firstQuery,缺失时从 store 获取,避免 followup 时 originalQuery 为空导致不保存
const effectiveOriginalQuery = firstQuery.value || (store.getSessionMeta && store.getSessionMeta()?.originalQuery) || ''
if (!effectiveOriginalQuery) {
uni.showToast({ title: '原始心愿已丢失,请重新开始', icon: 'none' })
answeringClarification.value = false
return
}
console.log('[ScriptView] submitClarification:', {
sessionId: novelSessionId.value,
originalQueryLength: effectiveOriginalQuery.length
})
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'answer_clarification',
payload: { answer }, // 后端仍收 value
originalQuery: effectiveOriginalQuery, // 首次心愿文本,用于后续 novel_done 保存
onEvent: handleShortNovelEvent,
onError: (errMsg) => markGenerationFailed(errMsg)
})
setTimeout(() => { answeringClarification.value = false }, 200)
}
const confirmOutline = (msg) => {
if (msg) msg.confirmed = true
pendingNextResponse.value = true
addResultMessage({ role: 'user', kind: 'text', content: '确认大纲' })
console.log('[ScriptView] confirmOutline:', {
sessionId: novelSessionId.value,
originalQueryLength: firstQuery.value?.length
})
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'confirm_outline',
payload: null,
originalQuery: firstQuery.value,
onEvent: handleShortNovelEvent,
onError: (errMsg) => markGenerationFailed(errMsg)
})
}
const modifyOutline = (msg) => {
const feedback = msg?.feedback || ''
if (!feedback.trim()) {
uni.showToast({ title: '请先填写修改意见', icon: 'none' })
return
}
if (msg) msg.confirmed = true
pendingNextResponse.value = true
addResultMessage({ role: 'user', kind: 'text', content: feedback })
console.log('[ScriptView] modifyOutline:', {
sessionId: novelSessionId.value,
originalQueryLength: firstQuery.value?.length
})
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'modify_outline',
payload: { feedback },
originalQuery: firstQuery.value,
onEvent: handleShortNovelEvent,
onError: (errMsg) => markGenerationFailed(errMsg)
})
}
const resumeSession = () => {
if (!resumeableSessionId.value || generating.value) return
novelSessionId.value = resumeableSessionId.value
resumeableSessionId.value = ''
generating.value = true
generationPhase.value = 'generating'
generationStatus.value = 'waiting'
generationError.value = ''
addResultMessage({ role: 'user', kind: 'text', content: '继续之前的创作' })
streamWriter.reset()
startGenerationFeedback()
keepResultAtBottom()
try {
console.log('[ScriptView] resumeSession:', {
sessionId: novelSessionId.value,
originalQueryLength: firstQuery.value?.length
})
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'retry',
payload: null,
originalQuery: firstQuery.value,
onEvent: handleShortNovelEvent,
onError: (errMsg) => {
markGenerationFailed(errMsg)
analytics.track('script_generate_fail', {
source: 'resume',
error: errMsg
}, { eventType: 'script', pagePath })
}
})
} catch (error) {
markGenerationFailed(error.message || '恢复失败')
} finally {
generating.value = false
}
}
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'
generationPhase.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>
.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%;
min-height: 64rpx;
max-height: 120rpx;
padding: 16rpx 20rpx;
background: rgba(255, 255, 255, 0.05);
border-radius: 12rpx;
color: #ffffff;
font-size: 28rpx;
line-height: 1.5;
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%;
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);
}
.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);
}
.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 24rpx 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: 72rpx;
height: 68rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 22rpx;
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);
}
.voice-icon {
font-size: 24rpx;
line-height: 1;
}
.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;
}
.version-bar {
display: flex;
gap: 16rpx;
margin-bottom: 24rpx;
flex-wrap: wrap;
}
.version-tag {
display: flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 20rpx;
border-radius: 999rpx;
background: rgba(43, 19, 83, 0.46);
border: 1rpx solid rgba(168, 85, 247, 0.24);
color: rgba(232, 204, 255, 0.72);
font-size: 26rpx;
}
.version-tag.active {
background: rgba(140, 68, 242, 0.52);
border-color: rgba(209, 138, 255, 0.52);
color: #fff;
}
.version-tag.current {
border-color: rgba(255, 216, 107, 0.42);
}
.version-current-label {
font-size: 22rpx;
color: #ffd86b;
}
.script-read-content {
display: flex;
flex-direction: column;
gap: 28rpx;
margin-bottom: 32rpx;
}
.script-title {
font-size: 40rpx;
font-weight: 800;
color: #fff;
text-align: center;
margin-bottom: 8rpx;
}
.script-section {
display: flex;
flex-direction: column;
gap: 12rpx;
padding: 24rpx;
border-radius: 28rpx;
background: rgba(16, 8, 34, 0.28);
border: 1rpx solid rgba(192, 132, 252, 0.2);
}
.script-section-title {
font-size: 30rpx;
font-weight: 700;
color: #d18aff;
}
.script-section-body {
font-size: 30rpx;
line-height: 48rpx;
color: rgba(255, 255, 255, 0.88);
white-space: pre-wrap;
word-break: break-all;
}
.read-action-bar {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 24rpx;
}
.read-action-btn {
flex: 1;
min-width: 176rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999rpx;
font-size: 27rpx;
font-weight: 700;
color: #1b0b31;
background: linear-gradient(145deg, #ffd86b, #d18aff);
box-shadow: 0 16rpx 36rpx rgba(209, 138, 255, 0.24);
}
.read-action-btn.secondary {
color: #e8ccff;
background: rgba(43, 19, 83, 0.68);
border: 1rpx solid rgba(168, 85, 247, 0.32);
box-shadow: none;
}
.read-action-btn.primary {
color: #1b0b31;
background: linear-gradient(145deg, #934dff, #4d1ccb);
box-shadow: 0 16rpx 36rpx rgba(168, 85, 247, 0.24);
}
.chat-page {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: #13091f;
box-sizing: border-box;
padding-bottom: env(safe-area-inset-bottom);
}
.chat-top-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 32rpx;
}
.chat-scroll {
position: relative;
z-index: 1;
flex: 1;
height: 100%;
min-height: 0;
/* 参考 .result-viewUniApp <scroll-view> 必须显式 height: 100% + min-height: 0 才能正确滚动 */
/* 仅 flex: 1 在真机上无法让原生 scroll-view 组件计算可滚动区域 */
}
.chat-scroll-content {
padding: 0 24rpx 40rpx;
}
.card-question-text {
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
margin-bottom: 16rpx;
line-height: 1.5;
display: block;
}
.card-answered {
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
padding: 16rpx 0;
}
.card-answer-row {
display: flex;
align-items: baseline;
gap: 8rpx;
margin-bottom: 12rpx;
}
.card-answer-prefix {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.6);
}
.card-answer-label {
font-size: 28rpx;
color: rgba(193, 134, 255, 0.95);
font-weight: 600;
}
.card-options-list {
display: flex;
flex-direction: column;
gap: 10rpx;
margin-top: 8rpx;
}
.card-option-item {
position: relative;
display: flex;
flex-direction: column;
gap: 4rpx;
padding: 14rpx 18rpx;
border-radius: 14rpx;
background: rgba(255, 255, 255, 0.04);
border: 1rpx solid rgba(255, 255, 255, 0.08);
}
.card-option-item.selected {
background: rgba(168, 85, 247, 0.16);
border-color: rgba(168, 85, 247, 0.45);
}
.card-option-label {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.92);
font-weight: 500;
}
.card-option-item.selected .card-option-label {
color: rgba(220, 195, 255, 1);
}
.card-option-desc {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.55);
line-height: 1.5;
}
.card-option-mark {
position: absolute;
right: 14rpx;
top: 50%;
transform: translateY(-50%);
font-size: 30rpx;
color: rgba(220, 195, 255, 1);
font-weight: 700;
}
.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: 24rpx;
}
.outline-feedback {
width: 100%;
min-height: 64rpx;
max-height: 120rpx;
padding: 16rpx 20rpx;
background: rgba(255, 255, 255, 0.05);
border-radius: 12rpx;
color: #ffffff;
font-size: 28rpx;
line-height: 1.5;
margin-bottom: 16rpx;
box-sizing: border-box;
}
.outline-buttons {
display: flex;
gap: 16rpx;
justify-content: flex-end;
}
.novel-text {
color: #ffffff;
font-size: 28rpx;
line-height: 1.8;
white-space: pre-wrap;
}
.typing-cursor {
color: #087e8b;
animation: blink 900ms steps(1) infinite;
}
.chat-loading {
display: flex;
align-items: center;
gap: 16rpx;
padding: 24rpx 0;
}
.resume-action {
padding: 24rpx 0;
display: flex;
justify-content: center;
}
.chat-input-bar {
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx 24rpx;
background: rgba(255, 255, 255, 0.04);
border-top: 2rpx solid rgba(255, 255, 255, 0.1);
}
.result-input-shell {
flex: 1;
background: rgba(255, 255, 255, 0.06);
border-radius: 12rpx;
padding: 8rpx 16rpx;
}
.result-chat-input {
width: 100%;
min-height: 60rpx;
padding: 12rpx 0;
color: #ffffff;
font-size: 28rpx;
}
.chat-send-btn {
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 32rpx;
background: #087e8b;
border-radius: 32rpx;
color: #ffffff;
font-size: 28rpx;
}
.chat-send-btn.disabled {
opacity: 0.5;
}
@keyframes blink {
50% { opacity: 0; }
}
</style>