diff --git a/docs/superpowers/specs/2026-07-21-sse-streaming-and-history-save-fix-design.md b/docs/superpowers/specs/2026-07-21-sse-streaming-and-history-save-fix-design.md index 09fd112..67641f6 100644 --- a/docs/superpowers/specs/2026-07-21-sse-streaming-and-history-save-fix-design.md +++ b/docs/superpowers/specs/2026-07-21-sse-streaming-and-history-save-fix-design.md @@ -133,10 +133,16 @@ log.info("[ShortNovel SSE] 完成读取上游响应"); ``` **关键点**: -- 使用 `readUtf8LineStrict()` 确保严格的行读取 -- 添加毫秒级时间戳日志,便于诊断 -- 在 `emitter.send()` 后发送空注释事件,触发 SseEmitter 的 flush -- 增加 `originalQuery.trim().isEmpty()` 检查,避免空字符串 +- 使用 `readUtf8LineStrict()` 替代 `readUtf8Line()`: + - `readUtf8Line()` 在遇到 EOF 时可能返回不完整的数据 + - `readUtf8LineStrict()` 严格遵循 SSE 规范,遇到 `\n` 或 `\r\n` 才返回一行 + - 这确保每行数据都是完整的 SSE 事件,避免缓冲导致的数据堆积 +- 添加毫秒级时间戳日志,便于诊断事件到达的时间间隔 +- 在 `emitter.send()` 后发送空注释事件,触发 SseEmitter 的 flush: + - Spring 的 `SseEmitter` 默认会缓冲事件,直到缓冲区满或连接关闭 + - 发送空注释 `comment("")` 会强制刷新缓冲区,确保事件立即发送到前端 + - 这是实现真正流式传输的关键步骤 +- 增加 `originalQuery.trim().isEmpty()` 检查,避免空字符串被误判为有效值 #### 2. nginx 配置优化 @@ -206,7 +212,20 @@ const handleShortNovelEvent = (event) => { hasFullText: !!payload.full_text, timestamp }) - // ... 其余逻辑 + // 标记最后一条 novel 消息完成 + const lastNovel = [...resultMessages.value].reverse().find(m => m.kind === 'novel') + if (lastNovel) { + if (payload.full_text) lastNovel.content = payload.full_text + lastNovel.pending = false + } + scriptId.value = payload.scriptId || '' + conversationId.value = payload.conversationId || '' + currentVersionMessageId.value = payload.currentVersionMessageId || '' + generationPhase.value = 'done' + generationStatus.value = 'idle' + pendingNextResponse.value = false + persistResultMessages() + store.fetchScripts() break } } @@ -217,17 +236,26 @@ const startNovelGeneration = async (source = 'home') => { const text = wishText.value.trim() if (!text) return - // ... 其他代码 + // ... 前面的验证代码 ... firstQuery.value = text // 确认设置首次心愿文本 console.log('[ScriptView] 设置 firstQuery:', { text, length: text.length }) - // ... 其余代码 + // ... 后面的初始化代码 ... } -// 在所有 followupStream 调用中确认 originalQuery 传递 +// 在 submitClarification 中添加日志确认 originalQuery 传递 const submitClarification = (answer) => { - // ... 其他代码 + if (answeringClarification.value) return + const card = [...resultMessages.value].reverse().find(m => m.kind === 'card' && !m.submitted) + const option = card?.card?.options?.find(opt => opt.value === answer) + const label = option?.label || answer + if (card) { + card.submitted = true + card.answer = label + } + pendingNextResponse.value = true + answeringClarification.value = true console.log('[ScriptView] submitClarification:', { sessionId: novelSessionId.value, originalQuery: firstQuery.value, @@ -241,9 +269,93 @@ const submitClarification = (answer) => { onEvent: handleShortNovelEvent, onError: (errMsg) => markGenerationFailed(errMsg) }) + setTimeout(() => { answeringClarification.value = false }, 200) } -// 类似地更新 confirmOutline, modifyOutline, resumeSession +// 在 confirmOutline 中添加日志和确认 originalQuery 传递 +const confirmOutline = (msg) => { + if (msg) msg.confirmed = true + pendingNextResponse.value = true + addResultMessage({ role: 'user', kind: 'text', content: '确认大纲,开始生成小说' }) + console.log('[ScriptView] confirmOutline:', { + sessionId: novelSessionId.value, + originalQuery: firstQuery.value, + originalQueryLength: firstQuery.value?.length + }) + currentStreamTask.value = followupStream({ + sessionId: novelSessionId.value, + action: 'confirm_outline', + payload: null, + originalQuery: firstQuery.value, + onEvent: handleShortNovelEvent, + onError: (errMsg) => markGenerationFailed(errMsg) + }) +} + +// 在 modifyOutline 中添加日志和确认 originalQuery 传递 +const modifyOutline = (msg, feedback) => { + if (!feedback.trim()) { + uni.showToast({ title: '请先填写修改意见', icon: 'none' }) + return + } + if (msg) msg.confirmed = true + pendingNextResponse.value = true + addResultMessage({ role: 'user', kind: 'text', content: feedback }) + console.log('[ScriptView] modifyOutline:', { + sessionId: novelSessionId.value, + originalQuery: firstQuery.value, + originalQueryLength: firstQuery.value?.length + }) + currentStreamTask.value = followupStream({ + sessionId: novelSessionId.value, + action: 'modify_outline', + payload: { feedback }, + originalQuery: firstQuery.value, + onEvent: handleShortNovelEvent, + onError: (errMsg) => markGenerationFailed(errMsg) + }) +} + +// 在 resumeSession 中添加日志和确认 originalQuery 传递 +const resumeSession = () => { + if (!resumeableSessionId.value || generating.value) return + novelSessionId.value = resumeableSessionId.value + resumeableSessionId.value = '' + generating.value = true + generationPhase.value = 'generating' + generationStatus.value = 'waiting' + generationError.value = '' + addResultMessage({ role: 'user', kind: 'text', content: '继续之前的创作' }) + streamWriter.reset() + startGenerationFeedback() + keepResultAtBottom() + console.log('[ScriptView] resumeSession:', { + sessionId: novelSessionId.value, + originalQuery: firstQuery.value, + originalQueryLength: firstQuery.value?.length + }) + + try { + currentStreamTask.value = followupStream({ + sessionId: novelSessionId.value, + action: 'retry', + payload: null, + originalQuery: firstQuery.value, + onEvent: handleShortNovelEvent, + onError: (errMsg) => { + markGenerationFailed(errMsg) + analytics.track('script_generate_fail', { + source: 'resume', + error: errMsg + }, { eventType: 'script', pagePath }) + } + }) + } catch (error) { + markGenerationFailed(error.message || '恢复失败') + } finally { + generating.value = false + } +} ``` **关键点**: