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:
2026-05-23 13:25:21 +08:00
parent d77090aa5e
commit 89fc42819d
72 changed files with 4584 additions and 383 deletions
+167 -18
View File
@@ -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);
}