feat: AI 打字机流式输出、小程序脚本主页布局及灵感卡片优化

- life-script: 新增 aiRuntime 打字机流式服务,PathView/ScriptView/TimelineView 接入打字机效果
- mini-program: ScriptView 重构为打字机输出 + 卡片式灵感列表,主页布局优化
- web: aiRuntime 服务新增流式输出支持
- chat store: 消息状态管理和打字机流式渲染支持

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 18:35:33 +08:00
parent c900f56174
commit 64476eee6d
21 changed files with 1474 additions and 205 deletions
+668 -64
View File
@@ -1,5 +1,14 @@
<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>
<view v-if="viewState === 'home'" class="wish-home">
<view class="home-head">
<view class="history-button" @click="openScriptLibrary">
@@ -21,28 +30,26 @@
</view>
<view class="hero-copy">
<text class="hero-title">今天有什么</text>
<text class="hero-title"><text class="hero-highlight">心愿</text>想实现</text>
<text class="hero-title">今天有什么<text class="hero-highlight">心愿</text>想实现</text>
</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 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>
<text class="voice-copy">{{ voiceCopy }}</text>
<view class="wish-input-wrap">
<input
class="wish-input"
@@ -68,44 +75,79 @@
/>
</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>
<text class="recommend-tag">{{ item.tag || item.category || '灵感' }}</text>
<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>
<view v-else-if="viewState === 'generating'" class="generation-view">
<scroll-view
class="generation-scroll"
scroll-y
:scroll-into-view="generationScrollTarget"
:scroll-with-animation="true"
:enhanced="true"
:show-scrollbar="false"
@touchstart="pauseGenerationAutoScroll"
@scrolltolower="resumeGenerationAutoScroll"
>
<view class="generation-scroll-content">
<view class="conversation">
<view class="chat-bubble user">
<text>{{ wishText }}</text>
<text class="bubble-time">{{ currentMessageTime }}</text>
</view>
<view class="chat-bubble system">
<text>心愿实现中</text>
<text v-if="streamContent" class="stream-preview">{{ streamContent }}</text>
<text>{{ generationTitle }}</text>
<view v-if="showThinkingDots" class="thinking-dots">
<view></view>
<view></view>
<view></view>
</view>
<text v-if="generationStatus === 'failed'" class="generation-error">{{ generationFailureCopy }}</text>
<text v-else-if="visibleStreamContent" class="stream-preview typing">{{ visibleStreamContent }}<text v-if="generating" class="typing-cursor">|</text></text>
<text class="bubble-time">{{ currentMessageTime }}</text>
</view>
</view>
<view class="loading-orbit">
<view class="generation-loading">
<view class="loading-orbit" :class="{ streaming: generationStatus === 'streaming', failed: generationStatus === 'failed' }">
<view class="orbit-ring outer"></view>
<view class="orbit-ring inner"></view>
<view class="orbit-core"></view>
</view>
<text class="loading-copy">正在把你的心愿写成故事</text>
<text class="loading-copy">{{ generationCopy }}</text>
<text v-if="generationStatus !== 'failed'" class="loading-subcopy">{{ generationSubcopy }}</text>
<view v-else class="generation-actions">
<button class="generation-action primary" @click="retryGeneration">再试一次</button>
<button class="generation-action secondary" @click="returnToEdit">返回修改</button>
</view>
</view>
<view :id="generationScrollAnchor" class="generation-scroll-anchor"></view>
</view>
</scroll-view>
</view>
<view v-else class="result-view">
<scroll-view
v-else
class="result-view"
scroll-y
:enhanced="true"
:show-scrollbar="false"
>
<view class="conversation compact">
<view class="chat-bubble user">
<text>{{ wishText }}</text>
@@ -141,17 +183,18 @@
<button class="action-btn primary" @click="trackTtsClick">{{ ttsButtonText }}</button>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useAppStore } from '../../stores/app.js'
import analytics from '../../services/analytics.js'
import * as socialImport from '../../services/socialImport.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'
@@ -166,6 +209,14 @@ const currentMessageTime = ref('')
const currentResultTime = ref('')
const streamContent = ref('')
const generating = ref(false)
const streamWriter = useTypewriterStream({ interval: 24, step: 1 })
const generationStatus = ref('idle')
const generationError = ref('')
const generationHintIndex = ref(0)
const generationScrollAnchor = ref('generation-stream-anchor-a')
const generationScrollTarget = ref('')
const generationAutoFollow = ref(true)
const lastSubmitSource = ref('text')
const remainingCount = ref(3)
const style = ref('career')
const randomRecommendations = ref([])
@@ -175,6 +226,17 @@ 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
const generationHints = [
'正在从你的心愿里寻找故事的起点',
'正在整理人生素材和情绪线索',
'正在安排一次更爽的命运转弯',
'正在让故事慢慢靠近你'
]
const fallbackRecommendations = [
{ text: '如果老板今天突然夸我,我的人生会怎样展开?', tag: '职场逆袭' },
@@ -211,6 +273,71 @@ const resultContent = computed(() => {
return currentResult.value?.content || currentResult.value?.summary || '故事正在生成,请稍后查看。'
})
const visibleStreamContent = computed(() => streamWriter.visibleText.value)
const scrollGenerationToLatest = () => {
if (viewState.value !== 'generating') return
if (!generationAutoFollow.value) return
if (generationScrollTimer) clearTimeout(generationScrollTimer)
generationScrollTimer = setTimeout(() => {
nextTick(() => {
generationScrollAnchor.value = generationScrollAnchor.value === 'generation-stream-anchor-a'
? 'generation-stream-anchor-b'
: 'generation-stream-anchor-a'
generationScrollTarget.value = generationScrollAnchor.value
})
}, 80)
}
const pauseGenerationAutoScroll = () => {
generationAutoFollow.value = false
}
const resumeGenerationAutoScroll = () => {
generationAutoFollow.value = true
scrollGenerationToLatest()
}
watch(visibleStreamContent, (text) => {
if (!text) return
scrollGenerationToLatest()
})
watch(generationStatus, () => {
scrollGenerationToLatest()
})
const generationTitle = computed(() => {
if (generationStatus.value === 'failed') return '心愿暂时没有抵达'
if (visibleStreamContent.value) return '故事正在展开'
return '心愿实现中……'
})
const showThinkingDots = computed(() => {
return generationStatus.value !== 'failed' && !visibleStreamContent.value
})
const generationCopy = computed(() => {
if (generationStatus.value === 'failed') return '这次生成没有顺利完成'
if (generationStatus.value === 'verySlow') return '这次需要久一点,仍在努力连接灵感'
if (generationStatus.value === 'slow') return '还在整理,请再给我一点时间'
if (streamWriter.isWaiting.value) return '正在理解你的心愿,整理人生素材'
if (streamWriter.isDraining.value) return '故事马上完成,正在收束最后一句'
if (streamWriter.isStreaming.value) return '正在把你的心愿写成故事'
return '正在把你的心愿写成故事'
})
const generationSubcopy = computed(() => {
if (generationStatus.value === 'verySlow') return '如果网络波动,稍后会给你温和提示,不会丢掉当前心愿。'
if (generationStatus.value === 'slow') return 'AI 还没有吐出第一句话,但请求仍在进行中。'
if (streamWriter.isStreaming.value || streamWriter.isDraining.value) return '已经收到内容,正在逐字展示给你。'
return generationHints[generationHintIndex.value]
})
const generationFailureCopy = computed(() => {
return generationError.value || '可能是网络慢了,或 AI 服务暂时没有回应。你可以直接再试一次,也可以返回修改心愿。'
})
const ttsButtonText = computed(() => {
if (!currentResult.value?.id) return '生成保存后可语音播放'
return ttsPlayer.buttonText.value
@@ -223,6 +350,54 @@ const formatMessageTime = () => {
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
@@ -387,12 +562,22 @@ const generateScriptByStream = async (text) => {
},
onDelta: (_delta, output) => {
streamContent.value = output
markGenerationStreaming()
streamWriter.push(output)
},
onDone: (_event, output) => {
streamWriter.finish(output)
},
onError: (message) => {
streamWriter.fail(message || 'AI 流式生成失败')
}
})
const content = streamRes.output?.trim()
if (!content) {
throw new Error('AI 流式输出为空')
}
streamWriter.finish(content)
await streamWriter.waitForDone()
const saveRes = await store.createScript({
title: text.length > 22 ? `${text.slice(0, 22)}...` : text,
theme: text,
@@ -424,6 +609,7 @@ const generateScriptByStream = async (text) => {
const submitWish = async (source = 'text') => {
const text = wishText.value.trim()
if (!text || generating.value) return
lastSubmitSource.value = source
analytics.track('script_wish_submit', {
source,
@@ -434,6 +620,11 @@ const submitWish = async (source = 'text') => {
generationStartedAt.value = Date.now()
generating.value = true
streamContent.value = ''
generationScrollTarget.value = ''
generationScrollAnchor.value = 'generation-stream-anchor-a'
generationAutoFollow.value = true
streamWriter.reset()
startGenerationFeedback()
ttsPlayer.reset()
viewState.value = 'generating'
analytics.track('script_generation_progress_view', {
@@ -445,6 +636,7 @@ const submitWish = async (source = 'text') => {
try {
res = await generateScriptByStream(text)
} catch (streamError) {
markGenerationFailed(streamError?.message || 'AI 流式生成失败')
analytics.track('script_generate_stream_fail', {
source,
error: streamError?.message || 'stream_failed'
@@ -456,6 +648,10 @@ const submitWish = async (source = 'text') => {
}
generating.value = false
if (res.success) {
clearGenerationFeedbackTimers()
if (streamContent.value) streamWriter.finish(streamContent.value)
}
if (!res.success) {
analytics.track('script_generate_fail', {
@@ -463,8 +659,7 @@ const submitWish = async (source = 'text') => {
error: res.error || 'unknown',
duration_ms: Date.now() - generationStartedAt.value
}, { eventType: 'script', pagePath })
viewState.value = 'home'
uni.showToast({ title: res.error || '生成失败', icon: 'none' })
markGenerationFailed(res.error || '生成失败')
return
}
@@ -485,10 +680,36 @@ const submitWish = async (source = 'text') => {
length: currentResult.value.length || ''
}, { eventType: 'script', pagePath })
generationStatus.value = 'idle'
viewState.value = 'result'
}
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 = ''
submitWish(lastSubmitSource.value || 'text')
}
const returnToEdit = () => {
clearGenerationFeedbackTimers()
generating.value = false
generationStatus.value = 'idle'
generationError.value = ''
streamContent.value = ''
streamWriter.reset()
analytics.track('script_generation_back_edit_click', {
prompt_length: wishText.value.trim().length
}, { eventType: 'script', pagePath })
viewState.value = 'home'
}
const closeResult = () => {
clearGenerationFeedbackTimers()
generationStatus.value = 'idle'
viewState.value = 'home'
currentResult.value = null
ttsPlayer.reset()
@@ -534,6 +755,12 @@ onMounted(() => {
})
onUnmounted(() => {
clearGenerationFeedbackTimers()
if (generationScrollTimer) {
clearTimeout(generationScrollTimer)
generationScrollTimer = null
}
streamWriter.dispose()
if (recorderManager && voiceState.value === 'pressing') {
recordCancelled = true
recorderManager.stop()
@@ -543,14 +770,177 @@ onUnmounted(() => {
<style scoped>
.script-view {
position: relative;
height: 100%;
min-height: 100%;
overflow: hidden;
color: #fff;
font-family: "PingFang SC", "Noto Sans SC", sans-serif;
}
.cosmic-background {
position: absolute;
inset: -80rpx -80rpx;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.cosmic-background::after {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 50% 22%, rgba(255, 216, 107, 0.06), transparent 30%),
radial-gradient(circle at 72% 6%, rgba(209, 138, 255, 0.11), transparent 24%),
linear-gradient(180deg, rgba(12, 4, 31, 0.1), rgba(5, 2, 13, 0.2));
}
.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: 118rpx;
right: -52rpx;
width: 188rpx;
height: 188rpx;
opacity: 0.38;
background:
radial-gradient(circle at 36% 28%, rgba(255, 231, 163, 0.96), rgba(209, 138, 255, 0.7) 36%, rgba(93, 38, 193, 0.78) 68%, rgba(23, 9, 56, 0.9));
box-shadow: 0 0 76rpx rgba(168, 85, 247, 0.32);
animation: planetDrift 9s ease-in-out infinite;
}
.planet-main::after {
content: '';
position: absolute;
left: -24rpx;
top: 78rpx;
width: 238rpx;
height: 34rpx;
border-radius: 50%;
border: 3rpx solid rgba(255, 216, 107, 0.18);
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: 132rpx;
height: 3rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.9), rgba(255, 216, 107, 0.18), transparent);
opacity: 0;
transform: rotate(-22deg);
animation: meteorFall 5.6s linear infinite;
}
.meteor-one {
top: 142rpx;
left: -160rpx;
}
.meteor-two {
top: 318rpx;
left: 120rpx;
width: 104rpx;
animation-delay: 1.8s;
}
.meteor-three {
top: 520rpx;
right: -120rpx;
width: 88rpx;
animation-delay: 3.4s;
}
@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);
}
9% {
opacity: 0.62;
}
34% {
opacity: 0;
transform: translate3d(520rpx, 210rpx, 0) rotate(-22deg);
}
100% {
opacity: 0;
transform: translate3d(520rpx, 210rpx, 0) rotate(-22deg);
}
}
.wish-home,
.generation-view,
.result-view {
position: relative;
z-index: 1;
min-height: 100%;
display: flex;
flex-direction: column;
@@ -562,6 +952,13 @@ onUnmounted(() => {
gap: 32rpx;
}
.result-view {
display: block;
height: 100%;
min-height: 0;
padding-bottom: 190rpx;
}
.home-head {
display: flex;
align-items: center;
@@ -623,17 +1020,28 @@ onUnmounted(() => {
.hero-copy {
margin-top: 12rpx;
margin-bottom: 14rpx;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
white-space: nowrap;
overflow: visible;
}
.hero-title {
display: block;
font-size: 76rpx;
display: inline-flex;
align-items: baseline;
flex-wrap: nowrap;
white-space: nowrap;
font-size: 56rpx;
font-weight: 800;
line-height: 1.26;
line-height: 1.18;
letter-spacing: 0;
}
.hero-highlight {
margin: 0 4rpx;
color: #d18aff;
text-shadow: 0 0 28rpx rgba(209, 138, 255, 0.52);
}
@@ -822,40 +1230,58 @@ onUnmounted(() => {
.recommend-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
gap: 14rpx;
}
.recommend-card {
min-height: 142rpx;
min-height: 68rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 16rpx;
padding: 22rpx;
border-radius: 36rpx;
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 {
font-size: 28rpx;
line-height: 40rpx;
width: 100%;
min-width: 0;
font-size: 26rpx;
line-height: 68rpx;
color: rgba(255, 255, 255, 0.92);
}
.recommend-tag {
align-self: flex-start;
padding: 5rpx 14rpx;
border-radius: 999rpx;
font-size: 22rpx;
color: #d18aff;
background: rgba(168, 85, 247, 0.22);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.generation-view {
justify-content: center;
gap: 52rpx;
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 {
@@ -892,14 +1318,78 @@ onUnmounted(() => {
color: rgba(255, 255, 255, 0.92);
}
.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;
max-height: 320rpx;
overflow: hidden;
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 {
@@ -920,6 +1410,24 @@ onUnmounted(() => {
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 {
@@ -932,6 +1440,22 @@ onUnmounted(() => {
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 {
@@ -939,6 +1463,41 @@ onUnmounted(() => {
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 {
@@ -947,6 +1506,51 @@ onUnmounted(() => {
color: rgba(255, 255, 255, 0.75);
}
.loading-subcopy {
display: block;
max-width: 620rpx;
text-align: center;
padding: 18rpx 24rpx;
border-radius: 28rpx;
color: rgba(255, 231, 163, 0.78);
font-size: 24rpx;
line-height: 36rpx;
background: rgba(255, 216, 107, 0.07);
border: 1rpx solid rgba(255, 216, 107, 0.14);
}
.generation-actions {
display: flex;
justify-content: center;
gap: 18rpx;
}
.generation-action {
min-width: 176rpx;
height: 72rpx;
margin: 0;
padding: 0 28rpx;
border-radius: 999rpx;
font-size: 27rpx;
line-height: 72rpx;
}
.generation-action::after {
border: 0;
}
.generation-action.primary {
color: #1b0b31;
background: linear-gradient(145deg, #ffd86b, #d18aff);
box-shadow: 0 16rpx 36rpx rgba(209, 138, 255, 0.24);
}
.generation-action.secondary {
color: #e8ccff;
background: rgba(43, 19, 83, 0.68);
border: 1rpx solid rgba(168, 85, 247, 0.32);
}
.story-card {
border-radius: 52rpx;
padding: 34rpx;