diff --git a/mini-program/src/pages/life-event/form.vue b/mini-program/src/pages/life-event/form.vue
index 77d7ff1..3856ed9 100644
--- a/mini-program/src/pages/life-event/form.vue
+++ b/mini-program/src/pages/life-event/form.vue
@@ -176,7 +176,7 @@ const pagePath = '/pages/life-event/form'
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
const saving = 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 form = reactive({
diff --git a/mini-program/src/pages/main/MineView.vue b/mini-program/src/pages/main/MineView.vue
index c57abc2..7136dd9 100644
--- a/mini-program/src/pages/main/MineView.vue
+++ b/mini-program/src/pages/main/MineView.vue
@@ -296,7 +296,22 @@ const isFavorite = (script) => {
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) => {
+ openScriptChat(script)
+}
+
+const openScriptDetail = (script) => {
if (!script?.id || String(script.id).startsWith('demo-')) return
uni.navigateTo({ url: `/pages/main/ScriptDetailView?id=${script.id}` })
}
@@ -346,7 +361,7 @@ const toggleViewMode = () => {
const openScriptMenu = (script) => {
const favorite = isFavorite(script)
uni.showActionSheet({
- itemList: [favorite ? '取消收藏' : '收藏剧本', '查看详情', '映射路径'],
+ itemList: [favorite ? '取消收藏' : '收藏剧本', '继续生成', '查看详情'],
success: ({ tapIndex }) => {
if (tapIndex === 0) {
const next = { ...localFavorites.value }
@@ -357,23 +372,11 @@ const openScriptMenu = (script) => {
uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' })
}
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' })
-}
diff --git a/mini-program/src/pages/main/ScriptView.vue b/mini-program/src/pages/main/ScriptView.vue
index 7d315e4..0aaeae6 100644
--- a/mini-program/src/pages/main/ScriptView.vue
+++ b/mini-program/src/pages/main/ScriptView.vue
@@ -27,6 +27,13 @@
+
+
+ 今天有什么
+ 心愿
+
+ 想实现
+
今天有什么心愿想实现
@@ -47,18 +54,6 @@
-
-
- 发送
-
-
{{ voiceCopy }}
+
+
+
+ 发送
+
@@ -92,7 +104,7 @@
- {{ wishText }}
+ {{ generationDisplayText }}
{{ currentMessageTime }}
@@ -125,49 +137,133 @@
-
-
-
- {{ wishText }}
- {{ currentMessageTime }}
-
-
- 心愿已实现,故事已为你展开
- {{ currentResultTime }}
-
-
+
+
+
+
+
+
+
+
+
+
+ 历史
+
+
+
-
-
-
- {{ currentResult?.title || '我的人生剧本' }}
-
- {{ tag }}
+
+
+ {{ wishText }}
+ {{ currentMessageTime }}
+
+
+ 心愿已实现,故事已为你展开
+ {{ currentResultTime }}
-
-
- {{ resultContent }}
+
+
+
+ {{ currentResult?.title || '我的人生剧本' }}
+
+ {{ tag }}
+
+
+
+
+
+
+
-
- ▶
- {{ ttsButtonText }}
-
+
+ {{ displayedResultContent }}
+
+ {{ displayedResultContent }}
+
+ {{ storyCollapsed ? '展开全文' : '收起全文' }}
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ {{ message.content }}
+ |
+ {{ message.time }}
+
+
+
+
+
+
+
+
+ 语音
+
+
+ 发送
-
+
@@ -192,14 +288,28 @@ const currentMessageTime = ref('')
const currentResultTime = ref('')
const streamContent = ref('')
const generating = ref(false)
-const streamWriter = useTypewriterStream({ interval: 24, step: 1 })
+const streamWriter = useTypewriterStream({ interval: 32, step: 1 })
+const resultChatWriter = useTypewriterStream({ interval: 32, step: 1 })
const generationStatus = ref('idle')
const generationError = ref('')
const generationHintIndex = ref(0)
const generationScrollAnchor = ref('generation-stream-anchor-a')
const generationScrollTarget = ref('')
const generationAutoFollow = ref(true)
+const generationDisplayText = ref('')
+const lastGenerationPrompt = ref('')
const lastSubmitSource = ref('text')
+const homeInputFocused = ref(false)
+const resultInputFocused = ref(false)
+const resultInputFocus = ref(false)
+const storyCollapsed = ref(false)
+const resultChatInput = ref('')
+const resultMessages = ref([])
+const resultChatting = ref(false)
+const resultScrollAnchor = ref('result-scroll-anchor-a')
+const resultScrollTarget = ref('')
+const activeAssistantMessageId = ref('')
+const revisionIntent = ref('')
const remainingCount = ref(3)
const style = ref('career')
const randomRecommendations = ref([])
@@ -212,6 +322,7 @@ let generationHintTimer = null
let generationSlowTimer = null
let generationVerySlowTimer = null
let generationScrollTimer = null
+let pendingOpenTimer = null
const generationHints = [
'正在从你的心愿里寻找故事的起点',
@@ -250,6 +361,23 @@ const resultContent = computed(() => {
return currentResult.value?.content || currentResult.value?.summary || '故事正在生成,请稍后查看。'
})
+const getInputLevel = (text, singleThreshold = 18, twoLineThreshold = 42) => {
+ const value = String(text || '')
+ const trimmedLength = value.trim().length
+ const explicitLines = value.split('\n').length
+ if (!trimmedLength) return 'single'
+ if (explicitLines >= 3 || trimmedLength > twoLineThreshold) return 'multi'
+ if (explicitLines === 2 || trimmedLength > singleThreshold) return 'two'
+ return 'single'
+}
+
+const homeInputLevel = computed(() => getInputLevel(wishText.value, 20, 46))
+const resultInputLevel = computed(() => getInputLevel(resultChatInput.value, 18, 42))
+
+const displayedResultContent = computed(() => {
+ return resultContent.value || ''
+})
+
const visibleStreamContent = computed(() => streamWriter.visibleText.value)
const scrollGenerationToLatest = () => {
@@ -275,6 +403,16 @@ const resumeGenerationAutoScroll = () => {
scrollGenerationToLatest()
}
+const scrollResultToLatest = () => {
+ if (viewState.value !== 'result') return
+ nextTick(() => {
+ resultScrollAnchor.value = resultScrollAnchor.value === 'result-scroll-anchor-a'
+ ? 'result-scroll-anchor-b'
+ : 'result-scroll-anchor-a'
+ resultScrollTarget.value = resultScrollAnchor.value
+ })
+}
+
watch(visibleStreamContent, (text) => {
if (!text) return
scrollGenerationToLatest()
@@ -284,6 +422,14 @@ watch(generationStatus, () => {
scrollGenerationToLatest()
})
+watch(resultChatWriter.visibleText, (text) => {
+ if (!activeAssistantMessageId.value) return
+ const message = resultMessages.value.find(item => item.id === activeAssistantMessageId.value)
+ if (!message) return
+ message.content = text
+ scrollResultToLatest()
+})
+
const generationTitle = computed(() => {
if (generationStatus.value === 'failed') return '心愿暂时没有抵达'
if (visibleStreamContent.value) return '故事正在展开'
@@ -315,9 +461,14 @@ const generationFailureCopy = computed(() => {
return generationError.value || '可能是网络慢了,或 AI 服务暂时没有回应。你可以直接再试一次,也可以返回修改心愿。'
})
-const ttsButtonText = computed(() => {
- if (!currentResult.value?.id) return '生成保存后可语音播放'
- return ttsPlayer.buttonText.value
+const ttsActionText = computed(() => {
+ if (!currentResult.value?.id) return '播放'
+ if (ttsPlayer.loading.value) return '生成中'
+ return ttsPlayer.playing.value ? '暂停' : '播放'
+})
+
+const ttsActionIcon = computed(() => {
+ return ttsPlayer.playing.value ? 'Ⅱ' : '▶'
})
const formatMessageTime = () => {
@@ -379,7 +530,7 @@ const normalizeGeneratedScript = (data) => {
const script = data?.script || data || {}
const latestScript = Array.isArray(store.scripts) && store.scripts.length ? store.scripts[0] : null
const merged = script?.id ? script : { ...latestScript, ...script }
- const content = merged?.content || merged?.plotJson?.fullContent || merged?.summary || ''
+ const content = normalizeGeneratedContent(merged?.content || merged?.plotJson?.fullContent || merged?.summary || '')
return {
id: merged?.id || '',
@@ -388,7 +539,7 @@ const normalizeGeneratedScript = (data) => {
style: merged?.style || '爽文',
length: merged?.length || 'medium',
tags: merged?.tags || [merged?.style || '爽文', '成长', '被看见'],
- summary: merged?.summary || content.slice(0, 90),
+ summary: normalizeGeneratedContent(merged?.summary || content.slice(0, 90)),
content
}
}
@@ -465,13 +616,19 @@ const endVoicePress = async () => {
}
const handleVoiceRecognizeSuccess = (text, durationMs) => {
- wishText.value = text
+ if (viewState.value === 'result') {
+ resultChatInput.value = text
+ resultInputFocused.value = true
+ } else {
+ wishText.value = text
+ homeInputFocused.value = true
+ }
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' })
+ uni.showToast({ title: viewState.value === 'result' ? '识别成功,可继续修改建议' : '识别成功,可修改后发送', icon: 'none' })
}
const handleVoiceRecognizeFail = (reason, message = '语音识别失败,请重试') => {
@@ -516,10 +673,212 @@ const setupRecorder = () => {
})
}
-const generateScriptByStream = async (text) => {
+const SCRIPT_CHAT_HISTORY_PREFIX = 'script_chat_history_'
+const PENDING_SCRIPT_CHAT_KEY = 'pending_open_script_chat'
+
+const createMessageId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+
+const getCurrentScriptId = () => String(currentResult.value?.id || '')
+
+const getScriptChatHistoryKey = (scriptId = getCurrentScriptId()) => {
+ return scriptId ? `${SCRIPT_CHAT_HISTORY_PREFIX}${scriptId}` : ''
+}
+
+const readScriptChatHistory = (scriptId) => {
+ const key = getScriptChatHistoryKey(scriptId)
+ if (!key) return []
+ const value = uni.getStorageSync(key)
+ return Array.isArray(value) ? value.filter(item => item?.role && typeof item.content === 'string') : []
+}
+
+const persistResultMessages = () => {
+ const key = getScriptChatHistoryKey()
+ if (!key) return
+ const messages = resultMessages.value
+ .filter(item => item?.role && item.content && !item.pending)
+ .map(item => ({
+ id: item.id,
+ role: item.role,
+ content: item.content,
+ time: item.time
+ }))
+ uni.setStorageSync(key, messages)
+}
+
+const hydrateResultMessages = (scriptId) => {
+ resultMessages.value = readScriptChatHistory(scriptId).map(item => ({
+ id: item.id || createMessageId(),
+ role: item.role,
+ content: item.content,
+ pending: false,
+ time: item.time || formatMessageTime()
+ }))
+}
+
+const addResultMessage = ({ role, content, pending = false }) => {
+ const message = {
+ id: createMessageId(),
+ role,
+ content,
+ pending,
+ time: formatMessageTime()
+ }
+ resultMessages.value.push(message)
+ if (!pending) persistResultMessages()
+ scrollResultToLatest()
+ return message
+}
+
+const buildRevisionPrompt = (suggestion) => {
+ return [
+ '请基于下面的当前小说和用户最新修改需求,重新生成一篇完整中文爽文小说。',
+ '要求:保留原始心愿的核心情绪,吸收用户修改建议,输出完整正文,不要输出解释说明。',
+ `原始心愿:${wishText.value || currentResult.value?.theme || ''}`,
+ `当前小说标题:${currentResult.value?.title || '我的人生剧本'}`,
+ `当前小说正文:${resultContent.value || ''}`,
+ `用户修改需求:${suggestion}`
+ ].join('\n\n')
+}
+
+const buildChatPrompt = (message) => {
+ return [
+ '你是如愿星球里帮助用户修改爽文小说的中文创作 agent。',
+ '请围绕当前小说回应用户的修改意见,先帮助用户确认方向、给出简短建议,不要直接重写全文。',
+ '如果用户表达已确认要重新生成,请提醒用户发送“确认生成”或给出更明确的修改方向。',
+ `原始心愿:${wishText.value || ''}`,
+ `当前小说标题:${currentResult.value?.title || '我的人生剧本'}`,
+ `当前小说正文:${resultContent.value || ''}`,
+ `用户消息:${message}`
+ ].join('\n\n')
+}
+
+const shouldRegenerateFromMessage = (message) => {
+ return /确认|生成|重写|重新|开始|就这样/.test(message)
+}
+
+const removeLeadingRepeatedPrefix = (text) => {
+ let output = String(text || '')
+ let changed = true
+ while (changed) {
+ changed = false
+ const maxPrefixLength = Math.floor(output.length / 2)
+ for (let size = maxPrefixLength; size >= 80; size -= 1) {
+ const prefix = output.slice(0, size)
+ if (output.slice(size).startsWith(prefix)) {
+ output = output.slice(size).trimStart()
+ changed = true
+ break
+ }
+ }
+ }
+ return output
+}
+
+const normalizeParagraphForCompare = (paragraph) => {
+ return String(paragraph || '')
+ .replace(/[#>*_`~\-【】「」『』“”"',。!?、;:,.!?;:\s]/g, '')
+ .trim()
+}
+
+const similarityRatio = (left, right) => {
+ if (!left || !right) return 0
+ const short = left.length <= right.length ? left : right
+ const long = left.length > right.length ? left : right
+ if (!short) return 0
+ if (long.includes(short)) return short.length / long.length
+ let same = 0
+ const max = Math.min(left.length, right.length)
+ for (let index = 0; index < max; index += 1) {
+ if (left[index] === right[index]) same += 1
+ }
+ return same / Math.max(left.length, right.length)
+}
+
+const isRepeatedParagraph = (current, previous) => {
+ const currentKey = normalizeParagraphForCompare(current)
+ const previousKey = normalizeParagraphForCompare(previous)
+ if (currentKey.length < 24 || previousKey.length < 24) return false
+ if (currentKey === previousKey) return true
+ return similarityRatio(currentKey, previousKey) >= 0.92
+}
+
+const removeRepeatedParagraphs = (text) => {
+ const paragraphs = String(text || '').split(/\n{2,}/)
+ const kept = []
+ const recent = []
+ paragraphs.forEach((paragraph) => {
+ const trimmed = paragraph.trim()
+ if (!trimmed) return
+ const repeated = recent.some((item) => isRepeatedParagraph(trimmed, item))
+ if (!repeated) {
+ kept.push(trimmed)
+ recent.push(trimmed)
+ if (recent.length > 8) recent.shift()
+ }
+ })
+ return kept.join('\n\n')
+}
+
+const normalizeGeneratedContent = (text) => {
+ return removeRepeatedParagraphs(removeLeadingRepeatedPrefix(String(text || '').trim()))
+}
+
+const openScriptChat = async (payload = {}) => {
+ uni.removeStorageSync(PENDING_SCRIPT_CHAT_KEY)
+ const scriptId = String(payload?.id || payload?.scriptId || '')
+ let script = payload?.script || (payload?.id ? payload : null)
+ if (!script?.id && scriptId) {
+ script = store.getScriptById(scriptId)
+ if (!script) {
+ await store.fetchScripts()
+ script = store.getScriptById(scriptId)
+ }
+ }
+ if (!script?.id) return
+
+ clearGenerationFeedbackTimers()
+ generating.value = false
+ generationStatus.value = 'idle'
+ generationError.value = ''
+ streamContent.value = ''
+ streamWriter.reset()
+ resultChatWriter.reset()
+ activeAssistantMessageId.value = ''
+ resultChatting.value = false
+ revisionIntent.value = ''
+ resultChatInput.value = ''
+ storyCollapsed.value = false
+ ttsPlayer.reset()
+
+ currentResult.value = normalizeGeneratedScript({ script })
+ wishText.value = script.theme || script.prompt || script.title || '继续修改这个人生剧本'
+ currentMessageTime.value = formatMessageTime()
+ currentResultTime.value = script.updateTime || script.updatedAt || script.createTime || script.createdAt || formatMessageTime()
+ hydrateResultMessages(script.id)
+ viewState.value = 'result'
+ analytics.track('script_chat_open_from_library', {
+ script_id: script.id,
+ has_history: resultMessages.value.length > 0
+ }, { eventType: 'script', pagePath })
+ scrollResultToLatest()
+}
+
+const openPendingScriptChat = async () => {
+ const pending = uni.getStorageSync(PENDING_SCRIPT_CHAT_KEY)
+ if (!pending) return
+ uni.removeStorageSync(PENDING_SCRIPT_CHAT_KEY)
+ await openScriptChat(pending)
+}
+
+const handleOpenScriptChat = (payload) => {
+ openScriptChat(payload)
+}
+
+const generateScriptByStream = async (text, options = {}) => {
const profile = store.userProfile || store.registrationData || {}
const characterInfo = epicScriptService.buildCharacterInfo(profile)
const lifeEventsSummary = epicScriptService.buildLifeEventsSummary(store.events || [], profile)
+ const saveTheme = options.saveTheme || text
const streamRes = await streamAiScene({
sceneCode: 'script_generate',
inputs: {
@@ -540,15 +899,15 @@ const generateScriptByStream = async (text) => {
streamWriter.fail(message || 'AI 流式生成失败')
}
})
- const content = streamRes.output?.trim()
+ const content = normalizeGeneratedContent(streamRes.output)
if (!content) {
throw new Error('AI 流式输出为空')
}
streamWriter.finish(content)
await streamWriter.waitForDone()
const saveRes = await store.createScript({
- title: text.length > 22 ? `${text.slice(0, 22)}...` : text,
- theme: text,
+ title: saveTheme.length > 22 ? `${saveTheme.slice(0, 22)}...` : saveTheme,
+ theme: saveTheme,
style: style.value,
length: 'medium',
content,
@@ -574,14 +933,17 @@ const generateScriptByStream = async (text) => {
}
}
-const submitWish = async (source = 'text') => {
- const text = wishText.value.trim()
+const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme }) => {
+ const text = String(prompt || '').trim()
+ const display = String(displayText || text).trim()
if (!text || generating.value) return
lastSubmitSource.value = source
+ generationDisplayText.value = display
+ lastGenerationPrompt.value = text
analytics.track('script_wish_submit', {
source,
- prompt_length: text.length
+ prompt_length: display.length
}, { eventType: 'script', pagePath })
currentMessageTime.value = formatMessageTime()
@@ -597,12 +959,12 @@ const submitWish = async (source = 'text') => {
viewState.value = 'generating'
analytics.track('script_generation_progress_view', {
source,
- prompt_length: text.length
+ prompt_length: display.length
}, { eventType: 'script', pagePath })
let res
try {
- res = await generateScriptByStream(text)
+ res = await generateScriptByStream(text, { saveTheme: saveTheme || display })
} catch (streamError) {
markGenerationFailed(streamError?.message || 'AI 流式生成失败')
analytics.track('script_generate_stream_fail', {
@@ -618,7 +980,7 @@ const submitWish = async (source = 'text') => {
generating.value = false
if (res.success) {
clearGenerationFeedbackTimers()
- if (streamContent.value) streamWriter.finish(streamContent.value)
+ if (streamContent.value) streamWriter.finish(normalizeGeneratedContent(streamContent.value))
}
if (!res.success) {
@@ -633,6 +995,7 @@ const submitWish = async (source = 'text') => {
currentResult.value = normalizeGeneratedScript(res.data)
currentResultTime.value = formatMessageTime()
+ resultMessages.value = []
if (typeof res.data?.remainingCount === 'number') remainingCount.value = res.data.remainingCount
analytics.track('script_generate_success', {
@@ -652,6 +1015,12 @@ const submitWish = async (source = 'text') => {
viewState.value = 'result'
}
+const submitWish = async (source = 'text') => {
+ const text = wishText.value.trim()
+ if (!text || generating.value) return
+ await runGeneration({ prompt: text, displayText: text, source, saveTheme: text })
+}
+
const retryGeneration = () => {
if (generating.value) return
analytics.track('script_generation_retry_click', {
@@ -659,7 +1028,12 @@ const retryGeneration = () => {
}, { eventType: 'script', pagePath })
generationStatus.value = 'idle'
generationError.value = ''
- submitWish(lastSubmitSource.value || 'text')
+ runGeneration({
+ prompt: lastGenerationPrompt.value || wishText.value.trim(),
+ displayText: wishText.value.trim(),
+ source: lastSubmitSource.value || 'text',
+ saveTheme: wishText.value.trim()
+ })
}
const returnToEdit = () => {
@@ -680,24 +1054,148 @@ const closeResult = () => {
generationStatus.value = 'idle'
viewState.value = 'home'
currentResult.value = null
+ resultMessages.value = []
+ resultChatInput.value = ''
+ revisionIntent.value = ''
+ storyCollapsed.value = false
ttsPlayer.reset()
}
+const toggleStoryCollapse = () => {
+ storyCollapsed.value = !storyCollapsed.value
+ analytics.track('script_result_story_collapse_toggle', {
+ script_id: currentResult.value?.id || '',
+ collapsed: storyCollapsed.value
+ }, { eventType: 'script', pagePath })
+}
+
+const copyResultContent = () => {
+ const content = resultContent.value || ''
+ if (!content.trim()) {
+ uni.showToast({ title: '暂无可复制内容', icon: 'none' })
+ return
+ }
+ uni.setClipboardData({
+ data: content,
+ success: () => {
+ uni.showToast({ title: '已复制全文', icon: 'success' })
+ }
+ })
+ analytics.track('script_result_copy_click', {
+ script_id: currentResult.value?.id || '',
+ content_length: content.length
+ }, { eventType: 'script', pagePath })
+}
+
+const continueInChat = () => {
+ storyCollapsed.value = true
+ resultInputFocus.value = true
+ setTimeout(() => {
+ resultInputFocus.value = false
+ }, 300)
+ scrollResultToLatest()
+ analytics.track('script_result_continue_click', {
+ script_id: currentResult.value?.id || ''
+ }, { eventType: 'script', pagePath })
+}
+
+const enterRevisionConfirm = (intent, copy) => {
+ revisionIntent.value = intent
+ resultChatInput.value = ''
+ addResultMessage({
+ role: 'assistant',
+ content: copy
+ })
+ scrollResultToLatest()
+}
+
const changeDirection = () => {
analytics.track('script_result_change_direction_click', {
script_id: currentResult.value?.id || ''
}, { eventType: 'script', pagePath })
- wishText.value = `${wishText.value},换一个方向重新展开`
- currentResult.value = null
- ttsPlayer.reset()
- viewState.value = 'home'
+ enterRevisionConfirm('change_direction', '我先不直接重写。你想换成更事业逆袭、更情感治愈,还是更强反转的方向?告诉我一句,我确认后再重新生成。')
}
const notLikeMe = () => {
analytics.track('script_result_not_like_me_click', {
script_id: currentResult.value?.id || ''
}, { eventType: 'script', pagePath })
- uni.showToast({ title: '已记录反馈,可以调整心愿后再试', icon: 'none' })
+ enterRevisionConfirm('not_like_me', '收到,我会先帮你确认“不像你”的地方。你可以说哪里不贴近你,比如性格、经历、情绪或结局,我再基于当前故事重写。')
+}
+
+const regenerateFromRevision = async (message, source = 'result_chat') => {
+ const prompt = buildRevisionPrompt(message)
+ revisionIntent.value = ''
+ resultChatInput.value = ''
+ resultChatting.value = false
+ resultMessages.value = []
+ storyCollapsed.value = false
+ await runGeneration({
+ prompt,
+ displayText: wishText.value || currentResult.value?.title || '根据当前故事继续修改',
+ source,
+ saveTheme: wishText.value || currentResult.value?.theme || '根据当前故事继续修改'
+ })
+}
+
+const sendResultChat = async (source = 'text') => {
+ const message = resultChatInput.value.trim()
+ if (!message || resultChatting.value || generating.value) return
+ resultChatInput.value = ''
+ addResultMessage({ role: 'user', content: message })
+ analytics.track('script_result_chat_send', {
+ source,
+ script_id: currentResult.value?.id || '',
+ has_revision_intent: Boolean(revisionIntent.value),
+ message_length: message.length
+ }, { eventType: 'script', pagePath })
+
+ if (revisionIntent.value || shouldRegenerateFromMessage(message)) {
+ await regenerateFromRevision(message, source)
+ return
+ }
+
+ resultChatting.value = true
+ resultChatWriter.reset()
+ const assistant = addResultMessage({
+ role: 'assistant',
+ content: '',
+ pending: true
+ })
+ activeAssistantMessageId.value = assistant.id
+
+ try {
+ const response = await streamAiScene({
+ sceneCode: 'script_generate',
+ inputs: {
+ prompt: buildChatPrompt(message),
+ style: style.value,
+ length: 'short',
+ useSocialInsights: false
+ },
+ onDelta: (_delta, output) => {
+ resultChatWriter.push(output)
+ },
+ onDone: (_event, output) => {
+ resultChatWriter.finish(output)
+ },
+ onError: (errorMessage) => {
+ resultChatWriter.fail(errorMessage || '暂时没有收到回复')
+ }
+ })
+ const output = response.output?.trim() || '我收到了。你可以继续补充,也可以直接说“确认生成”。'
+ resultChatWriter.finish(output)
+ await resultChatWriter.waitForDone()
+ } catch (error) {
+ resultChatWriter.fail(error?.message || '这次回复没有成功,可以稍后再试。')
+ } finally {
+ const current = resultMessages.value.find(item => item.id === assistant.id)
+ if (current) current.pending = false
+ activeAssistantMessageId.value = ''
+ resultChatting.value = false
+ persistResultMessages()
+ scrollResultToLatest()
+ }
}
const trackTtsClick = () => {
@@ -710,6 +1208,8 @@ const trackTtsClick = () => {
onMounted(() => {
analytics.track('script_home_view', {}, { eventType: 'script', pagePath })
setupRecorder()
+ uni.$on('openScriptChat', handleOpenScriptChat)
+ pendingOpenTimer = setTimeout(openPendingScriptChat, 300)
})
onUnmounted(() => {
@@ -718,7 +1218,13 @@ onUnmounted(() => {
clearTimeout(generationScrollTimer)
generationScrollTimer = null
}
+ if (pendingOpenTimer) {
+ clearTimeout(pendingOpenTimer)
+ pendingOpenTimer = null
+ }
streamWriter.dispose()
+ resultChatWriter.dispose()
+ uni.$off('openScriptChat', handleOpenScriptChat)
if (recorderManager && voiceState.value === 'pressing') {
recordCancelled = true
recorderManager.stop()
@@ -896,7 +1402,7 @@ onUnmounted(() => {
.wish-home,
.generation-view,
-.result-view {
+.result-page {
position: relative;
z-index: 1;
min-height: 100%;
@@ -907,14 +1413,52 @@ onUnmounted(() => {
}
.wish-home {
- gap: 32rpx;
+ gap: 36rpx;
+ padding-bottom: 28rpx;
+}
+
+.result-page {
+ height: 100%;
+ min-height: 0;
}
.result-view {
- display: block;
+ position: relative;
+ z-index: 1;
+ flex: 1;
height: 100%;
min-height: 0;
- padding-bottom: 190rpx;
+}
+
+.result-scroll-content {
+ min-height: 100%;
+ box-sizing: border-box;
+ padding: 0 0 220rpx;
+}
+
+.result-top-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 18rpx;
+}
+
+.page-close-btn {
+ width: 64rpx;
+ height: 64rpx;
+ line-height: 60rpx;
+ padding: 0;
+ border-radius: 50%;
+ color: rgba(255, 255, 255, 0.92);
+ background: rgba(255, 255, 255, 0.06);
+ border: 1rpx solid rgba(192, 132, 252, 0.36);
+ font-size: 42rpx;
+ backdrop-filter: blur(18rpx);
+ -webkit-backdrop-filter: blur(18rpx);
+}
+
+.page-close-btn::after {
+ border: 0;
}
.home-head {
@@ -948,6 +1492,20 @@ onUnmounted(() => {
box-shadow: 0 14rpx 34rpx rgba(129, 66, 255, 0.34);
}
+.history-button {
+ height: 58rpx;
+ display: inline-flex;
+ align-items: center;
+ gap: 12rpx;
+ padding: 0 20rpx;
+ border-radius: 999rpx;
+ color: rgba(232, 204, 255, 0.92);
+ font-size: 24rpx;
+ font-weight: 800;
+ background: rgba(43, 19, 83, 0.46);
+ border: 1rpx solid rgba(168, 85, 247, 0.24);
+}
+
.history-lines {
width: 28rpx;
display: flex;
@@ -962,36 +1520,54 @@ onUnmounted(() => {
}
.hero-copy {
- margin-top: 12rpx;
- margin-bottom: 14rpx;
+ margin-top: 26rpx;
+ margin-bottom: 30rpx;
display: flex;
+ flex-direction: column;
align-items: center;
justify-content: center;
+ gap: 8rpx;
text-align: center;
- white-space: nowrap;
overflow: visible;
}
.hero-title {
- display: inline-flex;
+ display: none;
+}
+
+.hero-title-new {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8rpx;
+}
+
+.hero-title-line {
+ display: flex;
align-items: baseline;
- flex-wrap: nowrap;
+ justify-content: center;
white-space: nowrap;
- font-size: 56rpx;
+ font-size: 60rpx;
font-weight: 800;
line-height: 1.18;
letter-spacing: 0;
}
+.hero-title-tail {
+ font-size: 58rpx;
+}
+
.hero-highlight {
- margin: 0 4rpx;
+ margin: 0 6rpx;
color: #d18aff;
text-shadow: 0 0 28rpx rgba(209, 138, 255, 0.52);
}
.orb-wrap {
position: relative;
- height: 330rpx;
+ height: 320rpx;
+ margin-top: 4rpx;
display: flex;
align-items: center;
justify-content: center;
@@ -1066,14 +1642,16 @@ onUnmounted(() => {
.voice-copy {
text-align: center;
- font-size: 36rpx;
+ font-size: 34rpx;
font-weight: 500;
line-height: 56rpx;
color: rgba(255, 255, 255, 0.92);
}
.wish-input-wrap {
- min-height: 96rpx;
+ min-height: 92rpx;
+ margin-top: auto;
+ margin-bottom: 6rpx;
display: flex;
align-items: center;
gap: 14rpx;
@@ -1082,13 +1660,39 @@ onUnmounted(() => {
background: linear-gradient(180deg, rgba(43, 19, 83, 0.72), rgba(32, 14, 61, 0.66));
border: 1rpx solid rgba(168, 85, 247, 0.42);
box-shadow: 0 0 22rpx rgba(116, 52, 202, 0.12);
+ transition: min-height 0.22s ease, border-radius 0.22s ease, border-color 0.22s ease, box-shadow 0.22s ease;
+}
+
+.wish-input-wrap.active {
+ border-color: rgba(209, 138, 255, 0.62);
+ box-shadow: 0 0 28rpx rgba(168, 85, 247, 0.18);
+}
+
+.wish-input-wrap.twoLine {
+ min-height: 126rpx;
+ align-items: flex-end;
+ border-radius: 42rpx;
+}
+
+.wish-input-wrap.expanded {
+ min-height: 158rpx;
+ align-items: flex-end;
+ border-radius: 36rpx;
}
.wish-input {
flex: 1;
- height: 72rpx;
+ height: 68rpx;
+ min-height: 72rpx;
+ max-height: 150rpx;
color: #fff;
font-size: 34rpx;
+ line-height: 48rpx;
+}
+
+.wish-input-wrap.twoLine .wish-input,
+.wish-input-wrap.expanded .wish-input {
+ height: auto;
}
.placeholder {
@@ -1115,7 +1719,9 @@ onUnmounted(() => {
.inspiration-section {
display: flex;
flex-direction: column;
- gap: 18rpx;
+ gap: 24rpx;
+ margin-top: 4rpx;
+ margin-bottom: 8rpx;
}
.section-line {
@@ -1125,12 +1731,13 @@ onUnmounted(() => {
}
.section-title {
- font-size: 44rpx;
- font-weight: 700;
+ font-size: 30rpx;
+ font-weight: 800;
}
.refresh {
font-size: 30rpx;
+ font-weight: 800;
color: #e8ccff;
}
@@ -1214,15 +1821,24 @@ onUnmounted(() => {
.chat-bubble.user {
align-self: flex-end;
- background: linear-gradient(145deg, rgba(140, 68, 242, 0.86), rgba(95, 29, 184, 0.9));
+ background: linear-gradient(145deg, rgba(140, 68, 242, 0.72), rgba(95, 29, 184, 0.64));
color: #fff;
+ border: 1rpx solid rgba(209, 138, 255, 0.16);
+ backdrop-filter: blur(10rpx);
+ -webkit-backdrop-filter: blur(10rpx);
}
.chat-bubble.system {
align-self: flex-start;
- background: rgba(255, 255, 255, 0.07);
- border: 1rpx solid rgba(192, 132, 252, 0.22);
+ background: rgba(16, 8, 34, 0.28);
+ border: 1rpx solid rgba(192, 132, 252, 0.3);
color: rgba(255, 255, 255, 0.92);
+ backdrop-filter: blur(8rpx);
+ -webkit-backdrop-filter: blur(8rpx);
+}
+
+.chat-bubble.pending {
+ border-color: rgba(255, 216, 107, 0.28);
}
.thinking-dots {
@@ -1461,9 +2077,15 @@ onUnmounted(() => {
.story-card {
border-radius: 52rpx;
padding: 34rpx;
- background: rgba(16, 8, 34, 0.72);
- border: 1rpx solid rgba(192, 132, 252, 0.55);
- box-shadow: 0 0 60rpx rgba(125, 55, 205, 0.18);
+ background: rgba(12, 5, 28, 0.34);
+ border: 1rpx solid rgba(192, 132, 252, 0.46);
+ box-shadow: 0 0 48rpx rgba(125, 55, 205, 0.14);
+ backdrop-filter: blur(6rpx);
+ -webkit-backdrop-filter: blur(6rpx);
+}
+
+.story-card.collapsed {
+ box-shadow: 0 0 42rpx rgba(125, 55, 205, 0.14);
}
.story-head {
@@ -1472,6 +2094,14 @@ onUnmounted(() => {
gap: 20rpx;
}
+.story-head-actions {
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 12rpx;
+}
+
.story-title-wrap {
flex: 1;
min-width: 0;
@@ -1484,22 +2114,115 @@ onUnmounted(() => {
line-height: 1.35;
}
-.close-icon {
- width: 64rpx;
- height: 64rpx;
- line-height: 60rpx;
- padding: 0;
- border-radius: 50%;
- color: rgba(255, 255, 255, 0.9);
- background: rgba(255, 255, 255, 0.07);
- border: 1rpx solid rgba(192, 132, 252, 0.32);
- font-size: 42rpx;
+.collapse-icon {
+ width: 120rpx;
+ min-width: 120rpx;
+ height: 58rpx;
+ min-height: 58rpx;
+ line-height: 1;
+ padding: 0 18rpx;
+ border-radius: 999rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8rpx;
+ color: rgba(255, 244, 255, 0.95);
+ background:
+ linear-gradient(135deg, rgba(134, 72, 255, 0.44), rgba(39, 15, 76, 0.42)),
+ rgba(255, 255, 255, 0.05);
+ border: 1rpx solid rgba(216, 180, 254, 0.46);
+ box-shadow:
+ 0 14rpx 30rpx rgba(61, 18, 113, 0.22),
+ inset 0 1rpx 0 rgba(255, 255, 255, 0.26),
+ inset 0 -10rpx 18rpx rgba(33, 9, 73, 0.16);
+ backdrop-filter: blur(8rpx);
+ -webkit-backdrop-filter: blur(8rpx);
+ font-size: 23rpx;
+ font-weight: 800;
+ letter-spacing: 0;
}
-.close-icon::after {
+.collapse-icon::after {
border: 0;
}
+.collapse-icon:active,
+.copy-card-btn:active,
+.collapse-row:active {
+ transform: scale(0.98);
+ opacity: 0.92;
+}
+
+.copy-card-btn {
+ width: 120rpx;
+ min-width: 120rpx;
+ height: 50rpx;
+ min-height: 50rpx;
+ line-height: 1;
+ padding: 0;
+ border-radius: 999rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: rgba(255, 244, 255, 0.9);
+ font-size: 23rpx;
+ font-weight: 800;
+ letter-spacing: 0;
+ background:
+ radial-gradient(circle at 50% -30%, rgba(255, 255, 255, 0.18), transparent 50%),
+ rgba(88, 28, 135, 0.26);
+ border: 1rpx solid rgba(216, 180, 254, 0.32);
+ box-shadow:
+ inset 0 1rpx 0 rgba(255, 255, 255, 0.16),
+ 0 10rpx 22rpx rgba(61, 18, 113, 0.12);
+ backdrop-filter: blur(8rpx);
+ -webkit-backdrop-filter: blur(8rpx);
+}
+
+.copy-card-btn::after {
+ border: 0;
+}
+
+.collapse-icon-text,
+.collapse-row-text {
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.collapse-chevron {
+ position: relative;
+ width: 20rpx;
+ height: 16rpx;
+ flex-shrink: 0;
+ transition: transform 0.18s ease;
+}
+
+.collapse-chevron.down {
+ transform: rotate(180deg);
+}
+
+.collapse-chevron view {
+ position: absolute;
+ top: 7rpx;
+ width: 12rpx;
+ height: 4rpx;
+ border-radius: 999rpx;
+ background: linear-gradient(90deg, #fff3b0, #ffd86b);
+ box-shadow: 0 0 12rpx rgba(255, 216, 107, 0.5);
+}
+
+.collapse-chevron view:first-child {
+ left: 0;
+ transform: rotate(-38deg);
+ transform-origin: right center;
+}
+
+.collapse-chevron view:last-child {
+ right: 0;
+ transform: rotate(38deg);
+ transform-origin: left center;
+}
+
.tag-row {
display: flex;
flex-wrap: wrap;
@@ -1525,44 +2248,55 @@ onUnmounted(() => {
white-space: pre-wrap;
}
-.audio-section {
+.story-body-scroll {
+ max-height: 420rpx;
margin-top: 28rpx;
- min-height: 76rpx;
- padding: 0 24rpx;
+ padding-right: 8rpx;
+ box-sizing: border-box;
+}
+
+.story-body-scroll .story-body {
+ margin-top: 0;
+}
+
+.collapse-row {
+ position: relative;
+ height: 62rpx;
+ margin-top: 22rpx;
+ padding: 0 28rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12rpx;
border-radius: 999rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 14rpx;
- color: #fff;
- background: linear-gradient(135deg, rgba(36, 198, 220, 0.82), rgba(127, 90, 240, 0.88));
- box-shadow: 0 12rpx 30rpx rgba(36, 198, 220, 0.18);
-}
-
-.audio-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-unavailable {
- color: rgba(255, 255, 255, 0.92);
- font-size: 25rpx;
+ color: rgba(246, 230, 255, 0.96);
+ font-size: 26rpx;
font-weight: 800;
- line-height: 1.2;
- text-align: center;
+ overflow: hidden;
+ background:
+ radial-gradient(circle at 50% -20%, rgba(255, 255, 255, 0.2), transparent 46%),
+ linear-gradient(135deg, rgba(98, 40, 174, 0.3), rgba(24, 8, 58, 0.24));
+ border: 1rpx solid rgba(192, 132, 252, 0.36);
+ box-shadow:
+ inset 0 1rpx 0 rgba(255, 255, 255, 0.16),
+ 0 12rpx 28rpx rgba(61, 18, 113, 0.14);
+ backdrop-filter: blur(6rpx);
+ -webkit-backdrop-filter: blur(6rpx);
+}
+
+.collapse-row::before {
+ content: '';
+ position: absolute;
+ left: 34rpx;
+ right: 34rpx;
+ top: 0;
+ height: 1rpx;
+ background: linear-gradient(90deg, transparent, rgba(255, 236, 180, 0.6), transparent);
}
.result-actions {
display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 26rpx;
}
@@ -1591,7 +2325,108 @@ onUnmounted(() => {
background: linear-gradient(145deg, #8c44f2, #5f1db8);
}
+.action-icon {
+ margin-right: 8rpx;
+ font-size: 24rpx;
+ font-weight: 900;
+ line-height: 1;
+}
+
.action-btn::after {
border: 0;
}
+
+.result-chat-list {
+ display: flex;
+ flex-direction: column;
+ gap: 18rpx;
+ margin-top: 24rpx;
+}
+
+.result-scroll-anchor {
+ width: 1rpx;
+ height: 28rpx;
+}
+
+.result-chat-bar {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 12;
+ min-height: 104rpx;
+ display: flex;
+ align-items: flex-end;
+ gap: 12rpx;
+ padding: 14rpx 0 22rpx;
+ box-sizing: border-box;
+ background: linear-gradient(180deg, rgba(5, 2, 13, 0), rgba(5, 2, 13, 0.9) 30%, rgba(5, 2, 13, 0.96));
+}
+
+.chat-voice-btn {
+ width: 86rpx;
+ height: 76rpx;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 26rpx;
+ color: #e8ccff;
+ font-size: 24rpx;
+ font-weight: 800;
+ background: rgba(88, 28, 135, 0.32);
+ border: 1rpx solid rgba(192, 132, 252, 0.3);
+}
+
+.chat-voice-btn.pressing,
+.chat-voice-btn.recognizing {
+ color: #fff;
+ background: linear-gradient(145deg, #934dff, #4d1ccb);
+ box-shadow: 0 0 30rpx rgba(168, 85, 247, 0.38);
+}
+
+.result-chat-input {
+ flex: 1;
+ height: 76rpx;
+ min-height: 76rpx;
+ max-height: 146rpx;
+ padding: 18rpx 22rpx;
+ box-sizing: border-box;
+ border-radius: 28rpx;
+ color: #fff;
+ font-size: 28rpx;
+ line-height: 40rpx;
+ background: rgba(43, 19, 83, 0.72);
+ border: 1rpx solid rgba(168, 85, 247, 0.36);
+}
+
+.result-chat-input.twoLine {
+ height: auto;
+ min-height: 110rpx;
+ border-radius: 30rpx;
+}
+
+.result-chat-input.expanded {
+ height: auto;
+ min-height: 144rpx;
+ border-radius: 30rpx;
+}
+
+.chat-send-btn {
+ width: 92rpx;
+ height: 76rpx;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 26rpx;
+ color: #fff;
+ font-size: 26rpx;
+ font-weight: 800;
+ background: linear-gradient(145deg, #934dff, #4d1ccb);
+}
+
+.chat-send-btn.disabled {
+ opacity: 0.45;
+}
diff --git a/mini-program/src/pages/main/index.vue b/mini-program/src/pages/main/index.vue
index 30d8c5f..95d06a9 100644
--- a/mini-program/src/pages/main/index.vue
+++ b/mini-program/src/pages/main/index.vue
@@ -18,6 +18,13 @@
+
+
+
+
+
+ 人生轨迹
+
@@ -26,13 +33,6 @@
爽文生成
-
-
-
-
-
- 人生轨迹
-
diff --git a/mini-program/src/services/aiRuntime.js b/mini-program/src/services/aiRuntime.js
index 57644a3..09f7fe5 100644
--- a/mini-program/src/services/aiRuntime.js
+++ b/mini-program/src/services/aiRuntime.js
@@ -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) => {
return new Promise((resolve, reject) => {
uni.request({
@@ -113,6 +140,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
let requestTask
let recoveryTimer
let recoveryPromise
+ let streamStarted = false
const clearRecoveryTimer = () => {
if (recoveryTimer) {
@@ -130,6 +158,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const completeFromRecoveredOutput = async () => {
if (closed) return
+ if (streamStarted || output.trim()) return
try {
const recoveredOutput = await recoverOnce()
if (closed) return
@@ -149,7 +178,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
clearRecoveryTimer()
recoveryTimer = setTimeout(() => {
completeFromRecoveredOutput()
- }, 8000)
+ }, 25000)
}
const finishRecovered = (message, event) => {
@@ -172,7 +201,6 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const recoverOrFail = async (message, event) => {
if (closed) return
- if (finishRecovered(message, event)) return
try {
const recoveredOutput = await recoverOnce()
if (closed) return
@@ -182,6 +210,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
resolve({ output })
} catch (recoverError) {
if (closed) return
+ if (finishRecovered(message || recoverError.message, event)) return
const finalMessage = message || recoverError.message || 'AI 生成结果暂时没有返回'
closed = true
clearRecoveryTimer()
@@ -196,10 +225,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const failWithoutRecovery = (message, event) => {
if (closed) return
- closed = true
- clearRecoveryTimer()
- onError?.(message, event)
- reject(new Error(message))
+ recoverOrFail(message, event)
}
const finishWithOutputOrRecover = async () => {
@@ -247,6 +273,8 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
requestTask?.onChunkReceived?.((res) => {
try {
+ streamStarted = true
+ clearRecoveryTimer()
consumeText(decodeChunk(res.data), failStream)
} catch (error) {
failStream(error.message || 'AI流式请求失败')
@@ -262,11 +290,20 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
const event = parseSseFrame(frame)
if (!event) return
if (event.type === 'start') {
+ streamStarted = true
+ clearRecoveryTimer()
onStart?.(event)
} else if (event.type === 'delta') {
- output += event.content || ''
- onDelta?.(event.content || '', output, event)
+ streamStarted = true
+ clearRecoveryTimer()
+ const merged = mergeStreamOutput(output, event.content)
+ output = merged.output
+ if (merged.delta) {
+ onDelta?.(merged.delta, output, event)
+ }
} else if (event.type === 'done') {
+ streamStarted = true
+ clearRecoveryTimer()
onDone?.(event, output)
} else if (event.type === 'error') {
const message = event.message || event.code || 'AI流式请求失败'
diff --git a/mini-program/src/services/tts.js b/mini-program/src/services/tts.js
index a63e836..93b3bb7 100644
--- a/mini-program/src/services/tts.js
+++ b/mini-program/src/services/tts.js
@@ -3,6 +3,8 @@ import { getEnvValue } from '../config/env.js'
const DEFAULT_SOURCE_TYPE = 'epic_script'
const DEFAULT_VOICE = 'default_zh_female'
+const DEFAULT_SPEECH_RATE = 0.92
+const DEFAULT_EMOTION = 'story'
const normalizeAudioUrl = (task) => {
if (!task?.audioUrl || /^https?:\/\//.test(task.audioUrl)) {
@@ -25,9 +27,12 @@ const normalizeResponse = (response) => {
export const createTtsTask = ({
sourceType = DEFAULT_SOURCE_TYPE,
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) => {
@@ -37,9 +42,12 @@ export const getTtsTask = (id) => {
export const getTtsTaskBySource = ({
sourceType = DEFAULT_SOURCE_TYPE,
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 {