feat: 小程序剧本、个人信息页、AI 运行时及 TTS 服务优化

This commit is contained in:
2026-05-26 20:50:20 +08:00
parent a51d225897
commit d09f600751
8 changed files with 1089 additions and 226 deletions
+1 -1
View File
@@ -176,7 +176,7 @@ const pagePath = '/pages/life-event/form'
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
const saving = ref(false)
const assisting = ref(false)
const assistWriter = useTypewriterStream({ interval: 24, step: 1 })
const assistWriter = useTypewriterStream({ interval: 32, step: 1 })
const currentYear = new Date().getFullYear()
const form = reactive({
+17 -14
View File
@@ -296,7 +296,22 @@ const isFavorite = (script) => {
return Boolean(script.isFavorite || script.favorite || localFavorites.value[String(script.id)])
}
const openScriptChat = (script) => {
if (!script?.id || String(script.id).startsWith('demo-')) return
uni.setStorageSync('pending_open_script_chat', {
id: script.id
})
uni.$emit('switchTab', 'script')
setTimeout(() => {
uni.$emit('openScriptChat', { id: script.id, script })
}, 80)
}
const viewScript = (script) => {
openScriptChat(script)
}
const openScriptDetail = (script) => {
if (!script?.id || String(script.id).startsWith('demo-')) return
uni.navigateTo({ url: `/pages/main/ScriptDetailView?id=${script.id}` })
}
@@ -346,7 +361,7 @@ const toggleViewMode = () => {
const openScriptMenu = (script) => {
const favorite = isFavorite(script)
uni.showActionSheet({
itemList: [favorite ? '取消收藏' : '收藏剧本', '查看详情', '映射路径'],
itemList: [favorite ? '取消收藏' : '收藏剧本', '继续生成', '查看详情'],
success: ({ tapIndex }) => {
if (tapIndex === 0) {
const next = { ...localFavorites.value }
@@ -357,23 +372,11 @@ const openScriptMenu = (script) => {
uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' })
}
if (tapIndex === 1) viewScript(script)
if (tapIndex === 2) mapScript(script)
if (tapIndex === 2) openScriptDetail(script)
}
})
}
const mapScript = async (script) => {
if (!script?.id || String(script.id).startsWith('demo-')) {
uni.showToast({ title: '示例剧本暂不可映射', icon: 'none' })
return
}
const res = await store.selectScript(script.id)
if (!res.success) {
uni.showToast({ title: res.error || '映射失败', icon: 'none' })
return
}
uni.navigateTo({ url: '/pages/main/PathView' })
}
</script>
<style scoped>
+1 -1
View File
@@ -53,7 +53,7 @@ const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
const pathData = ref(null)
const pathGenerating = ref(false)
const pathWriter = useTypewriterStream({ interval: 24, step: 1 })
const pathWriter = useTypewriterStream({ interval: 32, step: 1 })
const selectedScript = computed(() => {
return store.scripts.find(s => s.isSelected)
@@ -6,7 +6,7 @@
<view class="topbar" :style="topbarStyle">
<button class="back-btn" @click="goBack"></button>
<text class="top-title">人生剧本 </text>
<button class="save-btn kos-pill" @click="selectCurrent">映射</button>
<button class="save-btn kos-pill" @click="continueCurrent">继续</button>
</view>
<scroll-view class="scroll" scroll-y :show-scrollbar="false">
@@ -28,10 +28,6 @@
<text class="stat-label">字数</text>
</view>
</view>
<view class="audio-inline" @click="trackTtsClick">
<text class="audio-inline-icon"></text>
<text class="audio-inline-text">{{ detailTtsButtonText }}</text>
</view>
</view>
<view class="tabs kos-card">
@@ -56,7 +52,11 @@
<view class="bottom-actions">
<button class="secondary-btn kos-pill" @click="goBack">返回列表</button>
<button class="primary-btn kos-primary" @click="selectCurrent">映射实现路径</button>
<button class="voice-btn kos-pill" @click="trackTtsClick">
<text class="voice-icon">{{ detailTtsIcon }}</text>
<text>{{ detailTtsActionText }}</text>
</button>
<button class="primary-btn kos-primary" @click="continueCurrent">继续生成</button>
</view>
</view>
</template>
@@ -83,9 +83,14 @@ const lengthText = computed(() => {
return map[script.value?.length] || script.value?.length || '中篇'
})
const detailTtsButtonText = computed(() => {
if (!script.value?.id) return '生成保存后可语音播放'
return ttsPlayer.buttonText.value
const detailTtsActionText = computed(() => {
if (!script.value?.id) return '播放'
if (ttsPlayer.loading.value) return '生成中'
return ttsPlayer.playing.value ? '暂停' : '播放'
})
const detailTtsIcon = computed(() => {
return ttsPlayer.playing.value ? 'Ⅱ' : '▶'
})
const outline = computed(() => {
@@ -120,19 +125,17 @@ const loadScript = async () => {
}
}
const selectCurrent = async () => {
const continueCurrent = () => {
if (!script.value?.id) return
analytics.track('path_select', {
uni.setStorageSync('pending_open_script_chat', {
id: script.value.id
})
analytics.track('script_detail_continue_click', {
script_id: script.value.id,
style: script.value.style || '',
length: script.value.length || ''
}, { eventType: 'script', pagePath })
const res = await store.selectScript(script.value.id)
if (!res.success) {
uni.showToast({ title: res.error || '映射失败', icon: 'none' })
return
}
uni.navigateTo({ url: '/pages/main/PathView' })
uni.reLaunch({ url: '/pages/main/index?tab=script' })
}
const trackTtsClick = () => {
@@ -272,41 +275,6 @@ onUnmounted(() => {
margin-top: 28rpx;
}
.audio-inline {
height: 76rpx;
margin-top: 26rpx;
border-radius: 999rpx;
padding: 0 26rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
color: #fff;
font-size: 25rpx;
font-weight: 800;
line-height: 1;
background: linear-gradient(135deg, #24c6dc, #7f5af0);
box-shadow: 0 12rpx 30rpx rgba(36, 198, 220, 0.22);
}
.audio-inline-icon {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(5, 6, 21, 0.86);
font-size: 18rpx;
font-weight: 900;
background: rgba(255, 255, 255, 0.86);
}
.audio-inline-text {
line-height: 1.2;
text-align: center;
}
.stat {
padding: 18rpx 10rpx;
border-radius: 20rpx;
@@ -416,13 +384,14 @@ onUnmounted(() => {
padding: 16rpx 30rpx 26rpx;
box-sizing: border-box;
display: grid;
grid-template-columns: 1fr 1.45fr;
grid-template-columns: 1fr 1fr 1.24fr;
gap: 18rpx;
background: rgba(5, 6, 21, 0.72);
backdrop-filter: blur(24rpx);
}
.secondary-btn,
.voice-btn,
.primary-btn {
height: 82rpx;
border-radius: 999rpx;
@@ -439,4 +408,15 @@ onUnmounted(() => {
.secondary-btn {
color: #caa0ff;
}
.voice-btn {
gap: 8rpx;
color: #e8ccff;
border: 1rpx solid rgba(192, 132, 252, 0.32);
}
.voice-icon {
font-size: 22rpx;
font-weight: 900;
}
</style>
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -18,6 +18,13 @@
<view class="bottom-nav">
<view class="nav-inner">
<view class="nav-item" :class="{ active: activeTab === 'record' }" @click="switchTab('record')">
<view class="tab-icon planet-ring-icon">
<view class="planet-core"></view>
<view class="planet-ring"></view>
</view>
<text>人生轨迹</text>
</view>
<view class="nav-item" :class="{ active: activeTab === 'script' }" @click="switchTab('script')">
<view class="tab-icon book-star-icon">
<view class="book-page left"></view>
@@ -26,13 +33,6 @@
</view>
<text>爽文生成</text>
</view>
<view class="nav-item" :class="{ active: activeTab === 'record' }" @click="switchTab('record')">
<view class="tab-icon planet-ring-icon">
<view class="planet-core"></view>
<view class="planet-ring"></view>
</view>
<text>人生轨迹</text>
</view>
<view class="nav-item" :class="{ active: activeTab === 'mine' }" @click="switchTab('mine')">
<view class="tab-icon smile-face-icon">
<view class="smile-eye left"></view>
+45 -8
View File
@@ -44,6 +44,33 @@ const parseSseFrame = (frame) => {
}
}
const findOverlapLength = (current, next) => {
const max = Math.min(current.length, next.length)
for (let size = max; size > 0; size--) {
if (current.slice(-size) === next.slice(0, size)) return size
}
return 0
}
const mergeStreamOutput = (current, chunk) => {
const next = String(chunk || '')
if (!next) return { output: current, delta: '' }
if (!current) return { output: next, delta: next }
if (next === current) return { output: current, delta: '' }
if (next.length >= 16 && current.endsWith(next)) return { output: current, delta: '' }
if (next.startsWith(current)) {
return { output: next, delta: next.slice(current.length) }
}
const currentIndex = next.length > current.length ? next.indexOf(current) : -1
if (currentIndex >= 0) {
return { output: next, delta: next.slice(currentIndex + current.length) }
}
const overlap = findOverlapLength(current, next)
if (overlap < 8) return { output: current + next, delta: next }
const delta = next.slice(overlap)
return { output: current + delta, delta }
}
const queryRuntimeResult = (requestId) => {
return new Promise((resolve, reject) => {
uni.request({
@@ -113,6 +140,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
let requestTask
let recoveryTimer
let recoveryPromise
let streamStarted = false
const clearRecoveryTimer = () => {
if (recoveryTimer) {
@@ -130,6 +158,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const completeFromRecoveredOutput = async () => {
if (closed) return
if (streamStarted || output.trim()) return
try {
const recoveredOutput = await recoverOnce()
if (closed) return
@@ -149,7 +178,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
clearRecoveryTimer()
recoveryTimer = setTimeout(() => {
completeFromRecoveredOutput()
}, 8000)
}, 25000)
}
const finishRecovered = (message, event) => {
@@ -172,7 +201,6 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const recoverOrFail = async (message, event) => {
if (closed) return
if (finishRecovered(message, event)) return
try {
const recoveredOutput = await recoverOnce()
if (closed) return
@@ -182,6 +210,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
resolve({ output })
} catch (recoverError) {
if (closed) return
if (finishRecovered(message || recoverError.message, event)) return
const finalMessage = message || recoverError.message || 'AI 生成结果暂时没有返回'
closed = true
clearRecoveryTimer()
@@ -196,10 +225,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const failWithoutRecovery = (message, event) => {
if (closed) return
closed = true
clearRecoveryTimer()
onError?.(message, event)
reject(new Error(message))
recoverOrFail(message, event)
}
const finishWithOutputOrRecover = async () => {
@@ -247,6 +273,8 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
requestTask?.onChunkReceived?.((res) => {
try {
streamStarted = true
clearRecoveryTimer()
consumeText(decodeChunk(res.data), failStream)
} catch (error) {
failStream(error.message || 'AI流式请求失败')
@@ -262,11 +290,20 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const event = parseSseFrame(frame)
if (!event) return
if (event.type === 'start') {
streamStarted = true
clearRecoveryTimer()
onStart?.(event)
} else if (event.type === 'delta') {
output += event.content || ''
onDelta?.(event.content || '', output, event)
streamStarted = true
clearRecoveryTimer()
const merged = mergeStreamOutput(output, event.content)
output = merged.output
if (merged.delta) {
onDelta?.(merged.delta, output, event)
}
} else if (event.type === 'done') {
streamStarted = true
clearRecoveryTimer()
onDone?.(event, output)
} else if (event.type === 'error') {
const message = event.message || event.code || 'AI流式请求失败'
+12 -4
View File
@@ -3,6 +3,8 @@ import { getEnvValue } from '../config/env.js'
const DEFAULT_SOURCE_TYPE = 'epic_script'
const DEFAULT_VOICE = 'default_zh_female'
const DEFAULT_SPEECH_RATE = 0.92
const DEFAULT_EMOTION = 'story'
const normalizeAudioUrl = (task) => {
if (!task?.audioUrl || /^https?:\/\//.test(task.audioUrl)) {
@@ -25,9 +27,12 @@ const normalizeResponse = (response) => {
export const createTtsTask = ({
sourceType = DEFAULT_SOURCE_TYPE,
sourceId,
voice = DEFAULT_VOICE
voice = DEFAULT_VOICE,
speechRate = DEFAULT_SPEECH_RATE,
pitch = 0,
emotion = DEFAULT_EMOTION
}) => {
return post('/tts/tasks', { sourceType, sourceId, voice }).then(normalizeResponse)
return post('/tts/tasks', { sourceType, sourceId, voice, speechRate, pitch, emotion }).then(normalizeResponse)
}
export const getTtsTask = (id) => {
@@ -37,9 +42,12 @@ export const getTtsTask = (id) => {
export const getTtsTaskBySource = ({
sourceType = DEFAULT_SOURCE_TYPE,
sourceId,
voice = DEFAULT_VOICE
voice = DEFAULT_VOICE,
speechRate = DEFAULT_SPEECH_RATE,
pitch = 0,
emotion = DEFAULT_EMOTION
}) => {
return get('/tts/tasks/by-source', { sourceType, sourceId, voice }).then(normalizeResponse)
return get('/tts/tasks/by-source', { sourceType, sourceId, voice, speechRate, pitch, emotion }).then(normalizeResponse)
}
export default {