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
+55 -19
View File
@@ -1,5 +1,5 @@
<template>
<view class="path-view">
<view class="path-view" :style="{ paddingTop: capsuleTopReservePx + 'px' }">
<text class="page-title">实现路径</text>
<view v-if="!selectedScript" class="empty-state glass-card">
@@ -10,7 +10,7 @@
<view v-else-if="!currentPath" class="empty-state glass-card">
<text class="empty-icon">🎯</text>
<text class="empty-text">等待开启人生导航...</text>
<text class="empty-text">{{ pathEmptyText }}</text>
</view>
<view v-else class="path-content">
@@ -41,14 +41,19 @@
</template>
<script setup>
import { computed, ref, onMounted, watch } from 'vue'
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
import { useAppStore } from '../../stores/app.js'
import { useTypewriterStream } from '../../composables/useTypewriterStream.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
import * as lifePathService from '../../services/lifePath.js'
import { streamAiScene } from '../../services/aiRuntime.js'
const store = useAppStore()
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
const pathData = ref(null)
const pathGenerating = ref(false)
const pathWriter = useTypewriterStream({ interval: 24, step: 1 })
const selectedScript = computed(() => {
return store.scripts.find(s => s.isSelected)
@@ -60,6 +65,34 @@ const currentPath = computed(() => {
return null
})
const pathEmptyText = computed(() => {
if (pathWriter.isWaiting.value) return '正在分析剧本,拆解可执行路径...'
if (pathWriter.isStreaming.value || pathWriter.isDraining.value) return '正在生成路径节点...'
return '等待开启人生导航...'
})
const buildStreamPath = (scriptId, title, output) => ({
id: `stream-${scriptId}`,
scriptId,
title,
description: output,
steps: output.split('\n').filter(Boolean).slice(0, 6).map((line, index) => ({
phase: `阶段${index + 1}`,
task: line.replace(/^\d+[.、]\s*/, '').slice(0, 28),
desc: line,
content: line,
done: index === 0
})),
progress: 8,
status: 'active'
})
watch(() => pathWriter.visibleText.value, (text) => {
if (!(pathGenerating.value || pathWriter.isStreaming.value || pathWriter.isDraining.value) || !selectedScript.value?.id || !text) return
const title = selectedScript.value.title ? `${selectedScript.value.title} · 实现路径` : '我的实现路径'
pathData.value = buildStreamPath(selectedScript.value.id, title, text)
})
const loadPath = async (scriptId) => {
if (!scriptId) return
try {
@@ -76,6 +109,8 @@ const createPlaceholderPath = async (scriptId) => {
const script = selectedScript.value || {}
const title = script.title ? `${script.title} · 实现路径` : '我的实现路径'
let generatedText = ''
pathGenerating.value = true
pathWriter.reset()
try {
const streamRes = await streamAiScene({
sceneCode: 'life_healing',
@@ -86,26 +121,23 @@ const createPlaceholderPath = async (scriptId) => {
},
onDelta: (_delta, output) => {
generatedText = output
pathData.value = {
id: `stream-${scriptId}`,
scriptId,
title,
description: output,
steps: output.split('\n').filter(Boolean).slice(0, 6).map((line, index) => ({
phase: `阶段${index + 1}`,
task: line.replace(/^\d+[.、]\s*/, '').slice(0, 28),
desc: line,
content: line,
done: index === 0
})),
progress: 8,
status: 'active'
}
pathWriter.push(output)
},
onDone: (_event, output) => {
pathWriter.finish(output)
},
onError: (message) => {
pathWriter.fail(message || '路径生成失败')
}
})
generatedText = streamRes.output || generatedText
pathWriter.finish(generatedText)
await pathWriter.waitForDone()
} catch (error) {
pathWriter.fail(error?.message || '路径生成失败')
generatedText = ''
} finally {
pathGenerating.value = false
}
const steps = [
{ phase: '阶段1', task: '整理目标', desc: '把剧本中的关键目标拆成可以执行的小目标。', content: '把剧本中的关键目标拆成可以执行的小目标。', done: true },
@@ -128,7 +160,7 @@ const createPlaceholderPath = async (scriptId) => {
const res = await lifePathService.createPath({
scriptId,
title,
description: script.summary || '根据选中的人生剧本生成的占位实现路径,后续可接入AI生成更细的行动计划。',
description: generatedText || script.summary || '根据选中的人生剧本生成的占位实现路径,后续可接入AI生成更细的行动计划。',
steps,
progress: 8,
status: 'active'
@@ -164,6 +196,10 @@ onMounted(() => {
loadPath(selectedScript.value.id)
}
})
onUnmounted(() => {
pathWriter.dispose()
})
</script>
<style scoped>