feat: AI 场景路由、ASR 服务及前后端全链路同步
- 新增 AI 场景路由控制器和管理接口 - 新增 ASR 语音识别服务及前后端集成 - 同步 AI Runtime 客户端到 Web/小程序/Life-Script - 完善 AI 配置测试修复和管理后台路由配置 - 新增数据库迁移脚本 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,7 @@
|
||||
</view>
|
||||
<view class="chat-bubble system">
|
||||
<text>心愿实现中……</text>
|
||||
<text v-if="streamContent" class="stream-preview">{{ streamContent }}</text>
|
||||
<text class="bubble-time">{{ currentMessageTime }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -145,11 +146,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } 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 { transcribeAudio } from '../../services/asr.js'
|
||||
import { streamAiScene } from '../../services/aiRuntime.js'
|
||||
|
||||
const store = useAppStore()
|
||||
const pagePath = '/pages/main/ScriptView'
|
||||
@@ -160,6 +164,7 @@ const generationStartedAt = ref(0)
|
||||
const currentResult = ref(null)
|
||||
const currentMessageTime = ref('')
|
||||
const currentResultTime = ref('')
|
||||
const streamContent = ref('')
|
||||
const generating = ref(false)
|
||||
const remainingCount = ref(3)
|
||||
const style = ref('career')
|
||||
@@ -167,6 +172,9 @@ const randomRecommendations = ref([])
|
||||
const useSocialInsights = ref(true)
|
||||
const confirmedInsights = ref([])
|
||||
const ttsPlayer = useTtsPlayer({ pagePath })
|
||||
let recorderManager = null
|
||||
let recordStartedAt = 0
|
||||
let recordCancelled = false
|
||||
|
||||
const fallbackRecommendations = [
|
||||
{ text: '如果老板今天突然夸我,我的人生会怎样展开?', tag: '职场逆袭' },
|
||||
@@ -186,7 +194,7 @@ const socialInsightCopy = computed(() => {
|
||||
})
|
||||
|
||||
const voiceCopy = computed(() => {
|
||||
if (voiceState.value === 'pressing') return '松开后开始实现心愿'
|
||||
if (voiceState.value === 'pressing') return '松开后识别心愿'
|
||||
if (voiceState.value === 'recognizing') return '正在识别你的心愿……'
|
||||
if (voiceState.value === 'error') return '语音暂不可用,可以先输入文字'
|
||||
return '按住说话,即刻如愿'
|
||||
@@ -272,30 +280,145 @@ const shuffleInspirations = async () => {
|
||||
|
||||
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 = () => {
|
||||
voiceState.value = 'idle'
|
||||
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) => {
|
||||
wishText.value = text
|
||||
voiceState.value = 'idle'
|
||||
analytics.track('script_voice_recognize_success', {
|
||||
text_length: text.length,
|
||||
duration_ms: durationMs || 0
|
||||
}, { eventType: 'script', pagePath })
|
||||
uni.showToast({ title: '识别成功,可修改后发送', 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(() => {
|
||||
voiceState.value = 'error'
|
||||
analytics.track('script_voice_recognize_fail', {
|
||||
reason: 'speech_recognition_not_configured'
|
||||
}, { eventType: 'script', pagePath })
|
||||
uni.showToast({ title: '语音识别暂未配置,请先输入文字', icon: 'none' })
|
||||
if (voiceState.value === 'error') voiceState.value = 'idle'
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (voiceState.value === 'error') voiceState.value = 'idle'
|
||||
}, 1800)
|
||||
}, 300)
|
||||
const setupRecorder = () => {
|
||||
if (!uni.getRecorderManager) return
|
||||
recorderManager = uni.getRecorderManager()
|
||||
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', '录音失败,请检查麦克风权限')
|
||||
})
|
||||
}
|
||||
|
||||
const generateScriptByStream = async (text) => {
|
||||
const profile = store.userProfile || store.registrationData || {}
|
||||
const characterInfo = epicScriptService.buildCharacterInfo(profile)
|
||||
const lifeEventsSummary = epicScriptService.buildLifeEventsSummary(store.events || [], profile)
|
||||
const streamRes = await streamAiScene({
|
||||
sceneCode: 'script_generate',
|
||||
inputs: {
|
||||
prompt: text,
|
||||
style: style.value,
|
||||
length: 'medium',
|
||||
useSocialInsights: useSocialInsights.value
|
||||
},
|
||||
onDelta: (_delta, output) => {
|
||||
streamContent.value = output
|
||||
}
|
||||
})
|
||||
const content = streamRes.output?.trim()
|
||||
if (!content) {
|
||||
throw new Error('AI 流式输出为空')
|
||||
}
|
||||
const saveRes = await store.createScript({
|
||||
title: text.length > 22 ? `${text.slice(0, 22)}...` : text,
|
||||
theme: text,
|
||||
style: style.value,
|
||||
length: 'medium',
|
||||
content,
|
||||
plotJson: {
|
||||
mode: 'inspiration',
|
||||
prompt: text,
|
||||
source: 'mini-program-stream',
|
||||
fullContent: content
|
||||
},
|
||||
characterInfo,
|
||||
lifeEventsSummary,
|
||||
useSocialInsights: useSocialInsights.value
|
||||
})
|
||||
if (!saveRes.success) {
|
||||
throw new Error(saveRes.error || '保存剧本失败')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
script: saveRes.data,
|
||||
remainingCount: remainingCount.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const submitWish = async (source = 'text') => {
|
||||
@@ -310,6 +433,7 @@ const submitWish = async (source = 'text') => {
|
||||
currentMessageTime.value = formatMessageTime()
|
||||
generationStartedAt.value = Date.now()
|
||||
generating.value = true
|
||||
streamContent.value = ''
|
||||
ttsPlayer.reset()
|
||||
viewState.value = 'generating'
|
||||
analytics.track('script_generation_progress_view', {
|
||||
@@ -317,12 +441,19 @@ const submitWish = async (source = 'text') => {
|
||||
prompt_length: text.length
|
||||
}, { eventType: 'script', pagePath })
|
||||
|
||||
const res = await store.generateScriptFromInspiration({
|
||||
prompt: text,
|
||||
style: style.value,
|
||||
length: 'medium',
|
||||
useSocialInsights: useSocialInsights.value
|
||||
})
|
||||
let res
|
||||
try {
|
||||
res = await generateScriptByStream(text)
|
||||
} catch (streamError) {
|
||||
analytics.track('script_generate_stream_fail', {
|
||||
source,
|
||||
error: streamError?.message || 'stream_failed'
|
||||
}, { eventType: 'script', pagePath })
|
||||
res = {
|
||||
success: false,
|
||||
error: streamError?.message || 'AI 流式生成失败'
|
||||
}
|
||||
}
|
||||
|
||||
generating.value = false
|
||||
|
||||
@@ -398,8 +529,16 @@ const loadConfirmedInsights = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
analytics.track('script_home_view', {}, { eventType: 'script', pagePath })
|
||||
setupRecorder()
|
||||
loadConfirmedInsights()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (recorderManager && voiceState.value === 'pressing') {
|
||||
recordCancelled = true
|
||||
recorderManager.stop()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -753,6 +892,16 @@ onMounted(() => {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.chat-bubble.done {
|
||||
border-color: rgba(192, 132, 252, 0.42);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user