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:
+18
-12
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { upload } from './request.js'
|
||||
|
||||
export const transcribeAudio = (filePath) => {
|
||||
return upload('/asr/transcribe', filePath, {}, 'file')
|
||||
}
|
||||
|
||||
export default {
|
||||
transcribeAudio
|
||||
}
|
||||
@@ -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 = {}) => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user