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
+18 -12
View File
@@ -1,7 +1,6 @@
<script setup>
import { ref } from 'vue'
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
import { useAppStore } from './stores/app.js'
import { logRuntimeEnv } from './services/request.js'
import analytics from './services/analytics.js'
@@ -9,20 +8,27 @@ const statusBarHeight = ref(0)
const safeAreaTop = ref(0)
const safeAreaBottom = ref(0)
onLaunch(async () => {
const hydrateSafeArea = () => {
try {
const windowInfo = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync()
statusBarHeight.value = windowInfo.statusBarHeight || 20
safeAreaTop.value = windowInfo.safeAreaInsets?.top || windowInfo.statusBarHeight || 20
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
uni.setStorageSync('statusBarHeight', statusBarHeight.value)
uni.setStorageSync('safeAreaTop', safeAreaTop.value)
uni.setStorageSync('safeAreaBottom', safeAreaBottom.value)
} catch (error) {
statusBarHeight.value = 20
safeAreaTop.value = 20
safeAreaBottom.value = 0
}
}
onLaunch(() => {
console.log('App Launch')
analytics.initAnalytics()
logRuntimeEnv('app:onLaunch')
const store = useAppStore()
await store.initialize()
const windowInfo = uni.getWindowInfo()
statusBarHeight.value = windowInfo.statusBarHeight || 20
safeAreaTop.value = windowInfo.safeAreaInsets?.top || windowInfo.statusBarHeight || 20
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
uni.setStorageSync('statusBarHeight', statusBarHeight.value)
uni.setStorageSync('safeAreaTop', safeAreaTop.value)
uni.setStorageSync('safeAreaBottom', safeAreaBottom.value)
hydrateSafeArea()
})
onShow(() => {
+5 -1
View File
@@ -336,7 +336,11 @@ const loadEvent = async (id) => {
}
const assistWrite = async () => {
const result = await store.assistEventWriting({ ...form })
const result = await store.assistEventWriting({ ...form }, {
onDelta: (_delta, output) => {
form.content = output
}
})
if (!result.success) {
uni.showToast({ title: result.error || 'AI 帮写失败', icon: 'none' })
return
+45
View File
@@ -44,6 +44,7 @@
import { computed, ref, onMounted, watch } from 'vue'
import { useAppStore } from '../../stores/app.js'
import * as lifePathService from '../../services/lifePath.js'
import { streamAiScene } from '../../services/aiRuntime.js'
const store = useAppStore()
@@ -74,11 +75,55 @@ const loadPath = async (scriptId) => {
const createPlaceholderPath = async (scriptId) => {
const script = selectedScript.value || {}
const title = script.title ? `${script.title} · 实现路径` : '我的实现路径'
let generatedText = ''
try {
const streamRes = await streamAiScene({
sceneCode: 'life_healing',
inputs: {
mode: 'path_generate',
prompt: `请把下面的人生剧本拆解成现实中可执行的路径,按阶段输出。\n\n${script.content || script.summary || ''}`,
script: script.content || script.summary || ''
},
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'
}
}
})
generatedText = streamRes.output || generatedText
} catch (error) {
generatedText = ''
}
const steps = [
{ phase: '阶段1', task: '整理目标', desc: '把剧本中的关键目标拆成可以执行的小目标。', content: '把剧本中的关键目标拆成可以执行的小目标。', done: true },
{ phase: '阶段2', task: '建立习惯', desc: '选择一个最小行动,每天稳定推进。', content: '选择一个最小行动,每天稳定推进。', done: false },
{ phase: '阶段3', task: '复盘迭代', desc: '每周回看进展,根据现实反馈调整路径。', content: '每周回看进展,根据现实反馈调整路径。', done: false }
]
if (generatedText) {
const generatedSteps = generatedText.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
}))
if (generatedSteps.length) {
steps.splice(0, steps.length, ...generatedSteps)
}
}
try {
const res = await lifePathService.createPath({
scriptId,
+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);
}
+47 -16
View File
@@ -16,40 +16,72 @@
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useAppStore } from '../../stores/app.js'
import { logRuntimeEnv } from '../../services/request.js'
const statusBarHeight = ref(20)
const safeAreaBottom = ref(0)
const routed = ref(false)
let bootTimer = null
let fallbackTimer = null
onMounted(() => {
logRuntimeEnv('splash:onLoad')
// 使用新的推荐 API 替代已弃用的 getSystemInfoSync
const windowInfo = uni.getWindowInfo()
statusBarHeight.value = windowInfo.statusBarHeight || 20
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
setTimeout(async () => {
const routeOnce = (url, meta = {}) => {
if (routed.value) return
routed.value = true
if (bootTimer) clearTimeout(bootTimer)
if (fallbackTimer) clearTimeout(fallbackTimer)
console.log('[AUTH] route', { target: url, ...meta })
uni.reLaunch({ url })
}
const readWindowInfo = () => {
try {
const windowInfo = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync()
statusBarHeight.value = windowInfo.statusBarHeight || 20
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
} catch (error) {
statusBarHeight.value = uni.getStorageSync('statusBarHeight') || 20
safeAreaBottom.value = uni.getStorageSync('safeAreaBottom') || 0
}
}
const resolveInitialRoute = async () => {
try {
const store = useAppStore()
const session = await store.restoreSession()
if (session.status === store.SESSION_STATUS.AUTHENTICATED) {
const target = session.hasProfile ? '/pages/main/index' : '/pages/onboarding/index'
console.log('[AUTH] route', { target, reason: session.reason, hasProfile: session.hasProfile })
uni.reLaunch({ url: target })
routeOnce(target, { reason: session.reason, hasProfile: session.hasProfile })
return
}
if (session.status === store.SESSION_STATUS.ERROR) {
console.log('[AUTH] route', { target: '/pages/login/index', reason: session.reason })
uni.showToast({ title: '服务连接异常,请稍后重试', icon: 'none' })
uni.reLaunch({ url: '/pages/login/index' })
routeOnce('/pages/login/index', { reason: session.reason })
return
}
console.log('[AUTH] route', { target: '/pages/login/index', reason: session.reason })
uni.reLaunch({ url: '/pages/login/index' })
}, 2000)
routeOnce('/pages/login/index', { reason: session.reason || 'unauthenticated' })
} catch (error) {
console.error('[AUTH] splash route failed', error)
routeOnce('/pages/login/index', { reason: 'splash_exception', message: error?.message })
}
}
onMounted(() => {
logRuntimeEnv('splash:onLoad')
readWindowInfo()
bootTimer = setTimeout(resolveInitialRoute, 500)
fallbackTimer = setTimeout(() => {
routeOnce('/pages/login/index', { reason: 'splash_route_timeout' })
}, 3000)
})
onUnmounted(() => {
if (bootTimer) clearTimeout(bootTimer)
if (fallbackTimer) clearTimeout(fallbackTimer)
})
</script>
@@ -63,7 +95,6 @@ onMounted(() => {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 标题字体 - Cinzel (原型标准) */
.app-name.font-serif {
font-family: 'Cinzel', 'Inter', serif;
}
+120
View File
@@ -0,0 +1,120 @@
import { getEnvValue } from '../config/env.js'
const API_BASE_URL = getEnvValue('API_BASE_URL')
const getAuthHeader = () => {
const token = uni.getStorageSync('access_token')
return token ? { Authorization: `Bearer ${token}` } : {}
}
const decodeChunk = (arrayBuffer) => {
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder('utf-8').decode(arrayBuffer)
}
const bytes = new Uint8Array(arrayBuffer)
let binary = ''
bytes.forEach(byte => {
binary += String.fromCharCode(byte)
})
try {
return decodeURIComponent(escape(binary))
} catch (error) {
return binary
}
}
const parseSseFrame = (frame) => {
const event = { type: 'message', data: '' }
frame.split(/\r?\n/).forEach((line) => {
if (line.startsWith('event:')) event.type = line.slice(6).trim()
if (line.startsWith('data:')) event.data += line.slice(5).trim()
})
if (!event.data) return null
try {
return JSON.parse(event.data)
} catch (error) {
return { type: event.type, content: event.data }
}
}
export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone, onError }) => {
let buffer = ''
let output = ''
let closed = false
return new Promise((resolve, reject) => {
const failStream = (message, event) => {
if (closed) return
closed = true
onError?.(message, event)
reject(new Error(message))
}
const requestTask = uni.request({
url: `${API_BASE_URL}/ai/runtime/stream`,
method: 'POST',
data: { sceneCode, inputs },
header: {
'Content-Type': 'application/json',
...getAuthHeader()
},
enableChunked: true,
timeout: 120000,
success: (res) => {
if (closed) return
if (res.statusCode >= 400) {
const message = res.data?.message || 'AI流式请求失败'
failStream(message)
return
}
if (typeof res.data === 'string' && res.data) {
consumeText(res.data, failStream)
}
if (buffer.trim()) {
consumeText('\n\n', failStream)
}
closed = true
resolve({ output })
},
fail: (error) => {
failStream(error.errMsg || 'AI流式请求失败')
}
})
requestTask?.onChunkReceived?.((res) => {
try {
consumeText(decodeChunk(res.data), failStream)
} catch (error) {
failStream(error.message || 'AI流式请求失败')
}
})
})
function consumeText(text, failStream) {
buffer += text
const frames = buffer.split(/\r?\n\r?\n/)
buffer = frames.pop() || ''
frames.forEach((frame) => {
const event = parseSseFrame(frame)
if (!event) return
if (event.type === 'start') {
onStart?.(event)
} else if (event.type === 'delta') {
output += event.content || ''
onDelta?.(event.content || '', output, event)
} else if (event.type === 'done') {
onDone?.(event, output)
} else if (event.type === 'error') {
const message = event.message || event.code || 'AI流式请求失败'
failStream(message, event)
}
})
if (buffer.trim() && text === '') {
buffer = ''
}
}
}
export default {
streamAiScene
}
+9
View File
@@ -0,0 +1,9 @@
import { upload } from './request.js'
export const transcribeAudio = (filePath) => {
return upload('/asr/transcribe', filePath, {}, 'file')
}
export default {
transcribeAudio
}
+17 -2
View File
@@ -4,6 +4,7 @@
*/
import { get, post, put, del } from './request.js'
import { streamAiScene } from './aiRuntime.js'
export const getEventList = async () => {
const response = await get('/lifeEvent/list')
@@ -37,8 +38,22 @@ export const deleteEvent = async (id) => {
return response
}
export const assistEventWriting = async (eventData = {}) => {
return post('/lifeEvent/ai-assist', eventData)
export const assistEventWriting = async (eventData = {}, callbacks = {}) => {
const result = await streamAiScene({
sceneCode: 'life_healing',
inputs: {
mode: 'life_event_assist',
prompt: `请帮我优化这段人生事件记录,保留真实细节,让表达更清晰温柔。\n标题:${eventData.title || ''}\n时间:${eventData.time || eventData.eventDateText || ''}\n内容:${eventData.content || ''}`,
...eventData
},
...callbacks
})
return {
data: {
content: result.output,
tags: eventData.tags || []
}
}
}
export const chatAboutEvent = async (eventData = {}) => {
+4 -4
View File
@@ -171,9 +171,9 @@ const deleteEvent = async (id) => {
}
}
const assistEventWriting = async (eventData) => {
const assistEventWriting = async (eventData, callbacks = {}) => {
try {
const res = await lifeEventService.assistEventWriting(eventData)
const res = await lifeEventService.assistEventWriting(eventData, callbacks)
return { success: true, data: res.data }
} catch (error) {
return { success: false, error: error.message }
@@ -223,9 +223,9 @@ const fetchScripts = async () => {
const createScript = async (scriptData) => {
try {
await epicScriptService.createScript(scriptData)
const res = await epicScriptService.createScript(scriptData)
await fetchScripts()
return { success: true }
return { success: true, data: res.data }
} catch (error) {
return { success: false, error: error.message }
}