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 { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
const saving = ref(false) const saving = ref(false)
const assisting = 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 currentYear = new Date().getFullYear()
const form = reactive({ 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)]) 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) => { const viewScript = (script) => {
openScriptChat(script)
}
const openScriptDetail = (script) => {
if (!script?.id || String(script.id).startsWith('demo-')) return if (!script?.id || String(script.id).startsWith('demo-')) return
uni.navigateTo({ url: `/pages/main/ScriptDetailView?id=${script.id}` }) uni.navigateTo({ url: `/pages/main/ScriptDetailView?id=${script.id}` })
} }
@@ -346,7 +361,7 @@ const toggleViewMode = () => {
const openScriptMenu = (script) => { const openScriptMenu = (script) => {
const favorite = isFavorite(script) const favorite = isFavorite(script)
uni.showActionSheet({ uni.showActionSheet({
itemList: [favorite ? '取消收藏' : '收藏剧本', '查看详情', '映射路径'], itemList: [favorite ? '取消收藏' : '收藏剧本', '继续生成', '查看详情'],
success: ({ tapIndex }) => { success: ({ tapIndex }) => {
if (tapIndex === 0) { if (tapIndex === 0) {
const next = { ...localFavorites.value } const next = { ...localFavorites.value }
@@ -357,23 +372,11 @@ const openScriptMenu = (script) => {
uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' }) uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' })
} }
if (tapIndex === 1) viewScript(script) 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> </script>
<style scoped> <style scoped>
+1 -1
View File
@@ -53,7 +53,7 @@ const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
const pathData = ref(null) const pathData = ref(null)
const pathGenerating = ref(false) const pathGenerating = ref(false)
const pathWriter = useTypewriterStream({ interval: 24, step: 1 }) const pathWriter = useTypewriterStream({ interval: 32, step: 1 })
const selectedScript = computed(() => { const selectedScript = computed(() => {
return store.scripts.find(s => s.isSelected) return store.scripts.find(s => s.isSelected)
@@ -6,7 +6,7 @@
<view class="topbar" :style="topbarStyle"> <view class="topbar" :style="topbarStyle">
<button class="back-btn" @click="goBack"></button> <button class="back-btn" @click="goBack"></button>
<text class="top-title">人生剧本 </text> <text class="top-title">人生剧本 </text>
<button class="save-btn kos-pill" @click="selectCurrent">映射</button> <button class="save-btn kos-pill" @click="continueCurrent">继续</button>
</view> </view>
<scroll-view class="scroll" scroll-y :show-scrollbar="false"> <scroll-view class="scroll" scroll-y :show-scrollbar="false">
@@ -28,10 +28,6 @@
<text class="stat-label">字数</text> <text class="stat-label">字数</text>
</view> </view>
</view> </view>
<view class="audio-inline" @click="trackTtsClick">
<text class="audio-inline-icon"></text>
<text class="audio-inline-text">{{ detailTtsButtonText }}</text>
</view>
</view> </view>
<view class="tabs kos-card"> <view class="tabs kos-card">
@@ -56,7 +52,11 @@
<view class="bottom-actions"> <view class="bottom-actions">
<button class="secondary-btn kos-pill" @click="goBack">返回列表</button> <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>
</view> </view>
</template> </template>
@@ -83,9 +83,14 @@ const lengthText = computed(() => {
return map[script.value?.length] || script.value?.length || '中篇' return map[script.value?.length] || script.value?.length || '中篇'
}) })
const detailTtsButtonText = computed(() => { const detailTtsActionText = computed(() => {
if (!script.value?.id) return '生成保存后可语音播放' if (!script.value?.id) return '播放'
return ttsPlayer.buttonText.value if (ttsPlayer.loading.value) return '生成中'
return ttsPlayer.playing.value ? '暂停' : '播放'
})
const detailTtsIcon = computed(() => {
return ttsPlayer.playing.value ? 'Ⅱ' : '▶'
}) })
const outline = computed(() => { const outline = computed(() => {
@@ -120,19 +125,17 @@ const loadScript = async () => {
} }
} }
const selectCurrent = async () => { const continueCurrent = () => {
if (!script.value?.id) return 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, script_id: script.value.id,
style: script.value.style || '', style: script.value.style || '',
length: script.value.length || '' length: script.value.length || ''
}, { eventType: 'script', pagePath }) }, { eventType: 'script', pagePath })
const res = await store.selectScript(script.value.id) uni.reLaunch({ url: '/pages/main/index?tab=script' })
if (!res.success) {
uni.showToast({ title: res.error || '映射失败', icon: 'none' })
return
}
uni.navigateTo({ url: '/pages/main/PathView' })
} }
const trackTtsClick = () => { const trackTtsClick = () => {
@@ -272,41 +275,6 @@ onUnmounted(() => {
margin-top: 28rpx; 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 { .stat {
padding: 18rpx 10rpx; padding: 18rpx 10rpx;
border-radius: 20rpx; border-radius: 20rpx;
@@ -416,13 +384,14 @@ onUnmounted(() => {
padding: 16rpx 30rpx 26rpx; padding: 16rpx 30rpx 26rpx;
box-sizing: border-box; box-sizing: border-box;
display: grid; display: grid;
grid-template-columns: 1fr 1.45fr; grid-template-columns: 1fr 1fr 1.24fr;
gap: 18rpx; gap: 18rpx;
background: rgba(5, 6, 21, 0.72); background: rgba(5, 6, 21, 0.72);
backdrop-filter: blur(24rpx); backdrop-filter: blur(24rpx);
} }
.secondary-btn, .secondary-btn,
.voice-btn,
.primary-btn { .primary-btn {
height: 82rpx; height: 82rpx;
border-radius: 999rpx; border-radius: 999rpx;
@@ -439,4 +408,15 @@ onUnmounted(() => {
.secondary-btn { .secondary-btn {
color: #caa0ff; 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> </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="bottom-nav">
<view class="nav-inner"> <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="nav-item" :class="{ active: activeTab === 'script' }" @click="switchTab('script')">
<view class="tab-icon book-star-icon"> <view class="tab-icon book-star-icon">
<view class="book-page left"></view> <view class="book-page left"></view>
@@ -26,13 +33,6 @@
</view> </view>
<text>爽文生成</text> <text>爽文生成</text>
</view> </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="nav-item" :class="{ active: activeTab === 'mine' }" @click="switchTab('mine')">
<view class="tab-icon smile-face-icon"> <view class="tab-icon smile-face-icon">
<view class="smile-eye left"></view> <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) => { const queryRuntimeResult = (requestId) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
uni.request({ uni.request({
@@ -113,6 +140,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
let requestTask let requestTask
let recoveryTimer let recoveryTimer
let recoveryPromise let recoveryPromise
let streamStarted = false
const clearRecoveryTimer = () => { const clearRecoveryTimer = () => {
if (recoveryTimer) { if (recoveryTimer) {
@@ -130,6 +158,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const completeFromRecoveredOutput = async () => { const completeFromRecoveredOutput = async () => {
if (closed) return if (closed) return
if (streamStarted || output.trim()) return
try { try {
const recoveredOutput = await recoverOnce() const recoveredOutput = await recoverOnce()
if (closed) return if (closed) return
@@ -149,7 +178,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
clearRecoveryTimer() clearRecoveryTimer()
recoveryTimer = setTimeout(() => { recoveryTimer = setTimeout(() => {
completeFromRecoveredOutput() completeFromRecoveredOutput()
}, 8000) }, 25000)
} }
const finishRecovered = (message, event) => { const finishRecovered = (message, event) => {
@@ -172,7 +201,6 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const recoverOrFail = async (message, event) => { const recoverOrFail = async (message, event) => {
if (closed) return if (closed) return
if (finishRecovered(message, event)) return
try { try {
const recoveredOutput = await recoverOnce() const recoveredOutput = await recoverOnce()
if (closed) return if (closed) return
@@ -182,6 +210,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
resolve({ output }) resolve({ output })
} catch (recoverError) { } catch (recoverError) {
if (closed) return if (closed) return
if (finishRecovered(message || recoverError.message, event)) return
const finalMessage = message || recoverError.message || 'AI 生成结果暂时没有返回' const finalMessage = message || recoverError.message || 'AI 生成结果暂时没有返回'
closed = true closed = true
clearRecoveryTimer() clearRecoveryTimer()
@@ -196,10 +225,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const failWithoutRecovery = (message, event) => { const failWithoutRecovery = (message, event) => {
if (closed) return if (closed) return
closed = true recoverOrFail(message, event)
clearRecoveryTimer()
onError?.(message, event)
reject(new Error(message))
} }
const finishWithOutputOrRecover = async () => { const finishWithOutputOrRecover = async () => {
@@ -247,6 +273,8 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
requestTask?.onChunkReceived?.((res) => { requestTask?.onChunkReceived?.((res) => {
try { try {
streamStarted = true
clearRecoveryTimer()
consumeText(decodeChunk(res.data), failStream) consumeText(decodeChunk(res.data), failStream)
} catch (error) { } catch (error) {
failStream(error.message || 'AI流式请求失败') failStream(error.message || 'AI流式请求失败')
@@ -262,11 +290,20 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const event = parseSseFrame(frame) const event = parseSseFrame(frame)
if (!event) return if (!event) return
if (event.type === 'start') { if (event.type === 'start') {
streamStarted = true
clearRecoveryTimer()
onStart?.(event) onStart?.(event)
} else if (event.type === 'delta') { } else if (event.type === 'delta') {
output += event.content || '' streamStarted = true
onDelta?.(event.content || '', output, event) clearRecoveryTimer()
const merged = mergeStreamOutput(output, event.content)
output = merged.output
if (merged.delta) {
onDelta?.(merged.delta, output, event)
}
} else if (event.type === 'done') { } else if (event.type === 'done') {
streamStarted = true
clearRecoveryTimer()
onDone?.(event, output) onDone?.(event, output)
} else if (event.type === 'error') { } else if (event.type === 'error') {
const message = event.message || event.code || 'AI流式请求失败' 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_SOURCE_TYPE = 'epic_script'
const DEFAULT_VOICE = 'default_zh_female' const DEFAULT_VOICE = 'default_zh_female'
const DEFAULT_SPEECH_RATE = 0.92
const DEFAULT_EMOTION = 'story'
const normalizeAudioUrl = (task) => { const normalizeAudioUrl = (task) => {
if (!task?.audioUrl || /^https?:\/\//.test(task.audioUrl)) { if (!task?.audioUrl || /^https?:\/\//.test(task.audioUrl)) {
@@ -25,9 +27,12 @@ const normalizeResponse = (response) => {
export const createTtsTask = ({ export const createTtsTask = ({
sourceType = DEFAULT_SOURCE_TYPE, sourceType = DEFAULT_SOURCE_TYPE,
sourceId, 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) => { export const getTtsTask = (id) => {
@@ -37,9 +42,12 @@ export const getTtsTask = (id) => {
export const getTtsTaskBySource = ({ export const getTtsTaskBySource = ({
sourceType = DEFAULT_SOURCE_TYPE, sourceType = DEFAULT_SOURCE_TYPE,
sourceId, 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 { export default {