Compare commits

169 Commits

Author SHA1 Message Date
peanut daefcc89d6 fix(short-novel): 同步 SSE 事件顶层 session_id 修复澄清卡片累积丢失
上游 SSE 事件中 session_id 与 type 同级(不在 payload 内),原 forwardSse 仅在 status 事件从 payload 取 session_id,
导致首次 stream 的 sessionId 为 null,后续 clarification_card 因 sessionId 为空不进入全局累积器,
详情页首次进入时少显示第一张澄清卡片。

改为在每个事件处理前从顶层 session_id 同步 currentSessionId,确保所有事件链路都能拿到正确的 sessionId。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 22:55:21 +08:00
peanut 504ff8191a docs: 小程序 H5 测试登录凭证写入 CLAUDE.md 和 AGENTS.md
- 测试手机号固定使用 19928748688
- 测试验证码固定使用 123456(后端短信通道在测试模式下固定返回)
- 写入登录流程说明,便于每次做小程序功能验收直接参考

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 21:54:35 +08:00
peanut 44fe10950d feat(mini-program): ScriptDetailView 新增小说消息操作按钮组
- 新增 collapsedMessageIds ref + 5 个函数(isMessageCollapsed、getMessageDisplayContent、toggleMessageCollapse、copyMessageContent、playMessageTts)
- 为 kind === 'novel' 的消息添加折叠/展开、复制、播放按钮
- 添加 .collapse-toggle-row、.message-actions、.action-btn 样式
- H5 验收通过:复制按钮触发 Toast、播放按钮切换 TTS 状态(▶播放 ↔ Ⅱ暂停)、折叠按钮切换文案

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 21:53:24 +08:00
peanut 77ab1aee85 feat(mini-program): ScriptView 恢复小说消息操作按钮组
- 为 kind === 'novel' 的消息添加折叠/展开、复制、播放按钮
- 复用已有函数:getMessageDisplayContent、toggleMessageCollapse、copyMessageContent、playMessageTts
- 折叠按钮条件改用 ASSISTANT_MESSAGE_PREVIEW_LENGTH 常量(替代硬编码 120)
- 添加 .collapse-toggle-row、.message-actions、.action-btn 样式
- 修复 openScriptChat:payload 只有 id 时从 store 获取完整 script 数据,避免 conversationId 缺失导致 loadMessages 不调用
- H5 验收通过:复制/播放/折叠/展开功能正常

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 21:45:13 +08:00
peanut 318596c67e docs: 添加小说消息操作按钮恢复实施计划 2026-07-28 19:37:13 +08:00
peanut 760c1445fe docs: 添加小说消息操作按钮恢复设计文档 2026-07-28 19:32:28 +08:00
peanut 9808e2eb93 docs: 详情页澄清选项卡显示不全修复实施计划 2026-07-26 23:10:25 +08:00
peanut 4e79e2150b docs: 详情页澄清选项卡显示不全修复设计 2026-07-26 22:34:52 +08:00
peanut 502586b64a fix(mini-program): 修复 ScriptLibraryView 章节数展示为 0 的问题
getChapterCount 优先从 plotJson.stages.outline.beats.length 获取真实章节数(新剧本),
fallback 根据 length 字段估算:short→1章, medium→2章, long→4章。
2026-07-26 18:21:58 +08:00
peanut 321246c96c docs: 章节数展示修复设计文档 2026-07-26 18:18:14 +08:00
peanut 872cf63c7c fix:调整用户心愿消息到聊天流首位
问题:DB 中 messageOrder 顺序导致用户心愿(order=8)显示在澄清卡片(order=2,4)之后,
但逻辑上用户心愿应该是第一条消息。

修复:
- 在 rebuildResultMessages 完成后,检测 user 类型的 chat 消息位置
- 如果不在首位(index > 0),移动到 resultMessages 数组开头
- ScriptView.vue 和 ScriptDetailView.vue 保持一致

显示顺序现在为:
1. 用户心愿(第一条)
2. 澄清卡片 1 + 答案
3. 澄清卡片 2 + 答案
4. 大纲
5. 正文
2026-07-26 17:15:22 +08:00
peanut e373cb344f fix:过滤未配对澄清答案(避免显示孤立 answer 如「love」)
问题:DB 中部分 clarification_answer 没有对应 question(如 messageOrder=1 的「love」),
原逻辑将其渲染为独立 user 文本消息,导致用户看到无法理解的孤立答案气泡。

修复:
- 未配对的 clarification_answer 不再展示为 user 文本消息,直接 return 跳过
- 原因:没有 question 上下文的 answer 无法正确渲染,只显示答案会造成用户困惑
- ScriptView.vue 和 ScriptDetailView.vue 保持一致

数据影响:
- 「十年后的来信」剧本 messageOrder=1 的「love」answer 将被过滤
- 正常配对的 answer(如「family」→「家人」「sibling」→「兄弟姐妹」)仍正常显示
2026-07-26 17:11:52 +08:00
peanut ed67739a47 fix:澄清问答配对逻辑修复(按 messageOrder 升序匹配)
问题:DB 中 clarification_answer 可能先于 question 存入(messageOrder 更小),
导致原「找最后一张 card」的匹配逻辑把 answer 错误渲染成独立 user 文本消息
(如截图中的「love」紫色气泡)。

修复:
- rebuildResultMessages 改用 messageOrder 升序配对策略:
  1. 先分别提取所有 clarification_question 和 clarification_answer,按 messageOrder 排序
  2. 为每个 question 配对它之后第一个未分配的 answer
  3. 遍历时 question 替换为含答案的 card 消息,已配对 answer 跳过,未配对 answer 作为独立 user 文本

影响:
- ScriptView.vue(chat-bubble 流程视图)
- ScriptDetailView.vue(详情页只读视图)

数据基础:后端 answer 仍只存 value(语义化),前端渲染时通过 options 映射 label。
2026-07-26 17:07:22 +08:00
peanut 7d19fa5898 fix:澄清卡片显示完整选项和中文答案
问题:详情页 chat-bubble 卡片视图显示「已回答:family」等英文 value,
而不是中文 label(如「家人」),且不展示所有可选选项。

修复:
- 新增 getAnswerLabel(msg) 函数,通过 card.options 查找 value 对应的中文 label
- rebuildResultMessages 增加 selectedValue 字段,与 answer 同时存原始 value
- 卡片已回答视图新增 card-options-list,渲染所有选项,已选加 .selected 样式并标 ✓
- submitClarification(实时模式)同步存 selectedValue,保持渲染逻辑一致

影响:
- ScriptView.vue(chat-bubble 流程视图)
- ScriptDetailView.vue(详情页只读视图)

数据基础未动,后端澄清问答 answers 仍存原始 value(语义化)。
全局约束:rpx 单位、代码注释中文、禁止 mock 数据。
2026-07-26 16:49:44 +08:00
peanut 50fb2404fd fix:修复 ScriptView 详情面板重复展示及列表预览行数据路径
- ScriptView.openScriptChat 移除重复 push wishTheme/novelContent,改为从 messages 真实数据按 type 映射为 chat-bubble(与 ScriptDetailView.rebuildResultMessages 一致)
- ScriptLibraryView.getOutlinePreview 增加多路径探测(plotJson.stages.outline.beats / fullPayload.outline.beats / plotJson.outline.beats),兼容老剧本数据
2026-07-26 16:30:33 +08:00
peanut d220b01ba4 feat:ScriptLibraryView 新增大纲预览行
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 15:39:49 +08:00
peanut 95a3864250 feat:ScriptDetailView 改造为 chat-bubble 流程视图复刻生成时展示 2026-07-26 15:31:01 +08:00
peanut d2a051a938 feat:novel_done 读取累积 stages 并传入 saveNovelResult 2026-07-26 15:16:45 +08:00
peanut 8c2fe249e7 feat:saveNovelResult 创建 clarification/outline 中间步骤 message 2026-07-26 15:12:50 +08:00
peanut 87d668db1c feat:followup 累积用户澄清回答到全局缓存 2026-07-26 15:06:49 +08:00
peanut fcf4bcd9e4 feat:clarification_card 与 outline_created 事件累积到全局缓存
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 15:01:26 +08:00
peanut 7de91dbbb4 feat:新增 SESSION_STAGES_ACCUMULATOR 跨流中间步骤累积器
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 14:51:19 +08:00
peanut fc0f5b88c5 fix:修复小说保存与列表刷新
- ShortNovelServiceImpl:累积 novel_delta 事件,novel_done 未携带 full_text 时用累积内容兜底
- ScriptLibraryView:onMounted 主动拉取最新剧本,解决生成后列表不刷新问题
2026-07-26 14:45:51 +08:00
peanut 88e3ec32ef docs:小说列表/详情页展示改造实施计划 2026-07-26 14:43:29 +08:00
peanut 6f4d9ba0d8 docs:小说列表页/详情页展示改造设计文档
修复详情页重复展示问题,列表页增加大纲预览,后端保存澄清问答消息。
详情页复刻生成时 ScriptView 的 chat-bubble 展示结构。
2026-07-26 14:37:57 +08:00
peanut b5af8b4ce8 feat: ScriptDetailView 增强展示用户心愿和大纲元数据 2026-07-26 11:54:12 +08:00
peanut 0f0ae08b7e feat: 前端 store 增加 sessionMeta 状态 + ScriptView 增强 originalQuery 兜底和可靠刷新 2026-07-26 11:53:20 +08:00
peanut c0df76566d fix: 列表查询移除隐性写操作 2026-07-26 11:50:53 +08:00
peanut 06605bfd0d fix: 修复小说生成保存链路(事务+originalQuery兜底+中间步骤累积) 2026-07-26 11:49:31 +08:00
peanut 4931005102 docs: 心愿实现小说保存与复现完整性修复实施计划 2026-07-26 11:37:26 +08:00
peanut 644b8b9d2d docs: 心愿实现小说保存与复现完整性修复设计 2026-07-26 11:34:26 +08:00
peanut 1cf75a2b40 fix: 修复微信开发者工具 60 秒超时错误处理问题
- abort() 只清理资源,不再触发 onError(避免正常流程误报错误)
- 真正的错误(超时、网络错误)通过 Promise.race 的 catch 正确传递给用户
- 修复微信开发者工具强制终止 fetch 时 onError 不被调用的问题
- 使用 errorHandled 标志防止 onError 重复调用
2026-07-23 22:21:46 +08:00
peanut 6eadbce6f2 fix: 修复微信开发者工具 60 秒超时问题
- 将 AbortController 方案改为 Promise.race + setTimeout 外部超时控制
- 解决微信开发者工具底层 fetch 不兼容 AbortController signal 的问题
- 保持 300 秒超时时间不变
- 简化 abort 接口,统一使用 cleanup 函数
2026-07-23 22:12:42 +08:00
peanut bd706e4a5b fix: 修复 H5 模式 stream 请求 60 秒超时问题
- 在 H5 环境使用 fetch + ReadableStream + AbortController
- 支持自定义 300 秒超时
- 小程序环境保持原有 uni.request 逻辑不变
- 添加 CRLF 兼容性、主动中止与超时区分、资源清理
2026-07-22 22:57:29 +08:00
peanut 81786495a6 docs: H5 模式 stream 超时修复实施计划 2026-07-22 22:32:55 +08:00
peanut c64bb4285a docs: H5 模式 stream 超时修复设计文档 2026-07-22 22:31:52 +08:00
peanut e0d9c4a84b fix:修复 ClarificationCard 选项无法切换的 bug(增强 toggleOption 容错性) 2026-07-22 21:50:38 +08:00
peanut 235b08e01d docs:ClarificationCard 选项切换修复设计 2026-07-22 21:49:21 +08:00
peanut 6bf726d6a0 小程序对话页面优化 2026-07-22 21:36:11 +08:00
peanut ef23f3f51b docs: 记录 SSE 流式传输修复的测试结果 2026-07-21 23:54:51 +08:00
peanut bc2f9538f0 docs: 记录 SSE 流式传输修复的部署验证 2026-07-21 23:50:08 +08:00
peanut 82b138280b fix: 移除 console.log 中的敏感信息(firstQuery 文本、payload 完整内容) 2026-07-21 23:47:17 +08:00
peanut 748fc18c84 feat: 前端添加 SSE 事件诊断日志,确认 originalQuery 传递 2026-07-21 23:43:48 +08:00
peanut adeaa7a506 fix: 修复 nginx SSE 配置(添加 HTTP/1.1,移除冗余配置) 2026-07-21 23:40:38 +08:00
peanut feaf8922c5 docs: 记录 nginx SSE 配置优化 2026-07-21 23:34:23 +08:00
peanut 406f9b77b4 feat: 优化 SSE 转发逻辑,使用严格行读取和显式 flush 2026-07-21 23:28:00 +08:00
peanut 2b6dc72744 docs:创建 SSE 流式传输与历史列表保存修复实施计划 2026-07-21 23:24:14 +08:00
peanut 0c9ab9b974 docs:补充前端代码完整示例,移除占位符 2026-07-21 23:19:40 +08:00
peanut 461f993ab1 docs:SSE 流式传输与历史列表保存修复设计 2026-07-21 23:18:18 +08:00
peanut 3d1cc24fc4 fix: SSE 流式转发从 RestTemplate 改用 OkHttp,支持真正的逐行流式读取,解决缓冲导致的一下子全出来问题 2026-07-21 22:48:52 +08:00
peanut b24e176579 feat: 标签顺序改为全部/短篇/中篇/长篇/收藏夹;增加SSE事件诊断日志定位novel_done未触发问题 2026-07-21 21:56:22 +08:00
peanut 3813f91b8d feat: 历史列表分类改为固定5标签(全部/中篇/短篇/长篇/收藏夹),移除状态筛选,后端保存length改为short 2026-07-21 21:15:31 +08:00
peanut 9b496bb42a fix: 撤回 typewriter 改动,novel 改用直接累加 delta 保证流式输出
之前用 useTypewriterStream.writer.push + 同步读 visibleText 有时序问题:
writer 的 visibleText 由内部 setInterval 异步推进,push 后立即同步读得到空串,
气泡一直空白。

撤回 typewriter 改动,恢复 novel_delta 直接 lastNovel.content += delta,
SSE 本身就是流式推送,每次到达即显示。

保留 confirmOutline / modifyOutline 的 pendingNextResponse = true 修复(loading)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:43:11 +08:00
peanut 6afec090e4 fix: 大纲确认/修改时显示 loading + novel 消息使用 typewriter 逐字流式输出
- confirmOutline / modifyOutline 中设置 pendingNextResponse.value = true
  解决大纲确认/修改后等待响应期间无 loading 的问题
- novel_start 时为 novel 消息创建独立 typewriter writer 实例(novelWriters Map)
- novel_delta 时通过 writer.push() 推送增量,逐字渲染 visibleText
- novel_done 时 finish writer 并同步最终文本,清理 writer 实例

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:33:52 +08:00
peanut 7b53821cec fix: 澄清卡片 allow_custom 时允许纯自定义输入提交
- canSubmit 逻辑重构:纯文本卡片只需 customText 非空
- 有选项卡片:满足最小选择数,或(allow_custom 且 customText 非空)即可提交
- 修复其他,请简单描述输入自定义内容后提交按钮灰显的问题

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:25:24 +08:00
peanut 9a215a89e9 fix: 新生成短篇剧本刷新到历史列表 + 分类标签从实际数据动态生成
- novel_done 事件处理中调用 store.fetchScripts() 刷新剧本列表
- typeTabs 从硬编码改为 computed,根据实际 length 值动态生成
- activeType 默认值从 'long' 改为 'all'(显示全部)
- 过滤逻辑改为精确匹配 length 字段
- 移除无效的 '风格' 硬编码分类标签

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 08:39:14 +08:00
peanut 1f5ad3abe2 fix: 大纲修改意见输入框改 textarea,增高并完整显示 placeholder
- input 改为 textarea + auto-height,支持多行输入
- min-height 64rpx 保证 placeholder 完整显示(原来被压扁截断)
- max-height 120rpx 限制约 2 行,超出滚动显示最新底部内容
- line-height 1.5 比例协调,改动幅度小

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:52:46 +08:00
peanut 7c3bd5c0e3 fix: 心愿实现页面用 pendingNextResponse 标志修复第二轮 loading 显示
- 新增 pendingNextResponse ref 标志等待下一个 assistant 响应
- showChatLoading 改用该标志判断,替代不可靠的最后消息 role 判断
- submitClarification 移除重复 addResultMessage,消除重复用户气泡
- 规避 mp-weixin 对 v-for 中 computed 数组的错误编译导致的页面空白
- runGeneration/clarification/outline/novel_start/novel_done 正确设置标志

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:37:21 +08:00
peanut 4b3fa1552c fix: 第二轮 loading 不显示 — 用 filteredResultMessages 替代函数调用,避免 mp-weixin 编译丢失 2026-07-20 23:08:42 +08:00
peanut f8d6e881df fix: 恢复 addResultMessage + 渲染层过滤,避免重复气泡同时支持后续 loading 2026-07-20 22:57:24 +08:00
peanut 4afc632cac fix: 移除澄清卡片提交后的重复用户气泡(label 已在卡片内显示) 2026-07-20 21:55:35 +08:00
peanut f1ad9a6b47 test: H5 验证澄清卡片 label 显示 + AI 问题显示 2026-07-20 21:36:13 +08:00
peanut 180d21b66c fix: 已回答状态显示 AI 的问题(question) 2026-07-20 21:28:03 +08:00
peanut 41c2112c5b fix: 澄清卡片已回答用 label 显示,后端 payload 仍用 value 2026-07-20 21:25:09 +08:00
peanut 412d3f3a9a docs: 澄清卡片已回答显示修复实施计划 2026-07-20 21:17:34 +08:00
peanut 8693b3febc docs: 澄清卡片已回答显示修复设计(label 替代 value + 显示 AI 问题) 2026-07-20 21:15:36 +08:00
peanut d560f63d70 fix: chat 视图 loading 在等待首个 assistant 响应期间持续显示 2026-07-20 00:19:19 +08:00
peanut 979fcb5897 docs: 心愿实现 chat 视图 loading 标识修复实施计划 2026-07-20 00:17:18 +08:00
peanut 95ce23f172 docs: 心愿实现 chat 视图 loading 标识修复设计 2026-07-20 00:16:05 +08:00
peanut 4884099b9b fix: 收藏 toggle 用原生 SQL 绕过逻辑删除,彻底解决重新收藏冲突 2026-07-19 23:59:54 +08:00
peanut c0245d37fb fix: 收藏 toggle 支持重新收藏(修复逻辑删除与 unique key 冲突) 2026-07-19 23:56:41 +08:00
peanut 3666c3d530 fix: 收藏 toggle 增加实体存在性和权限校验(IDOR 修复) 2026-07-19 23:42:53 +08:00
peanut 33b24450db feat: 人生事件和剧本收藏从 localStorage 切换为真实 API
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 23:24:04 +08:00
peanut dd11cfae73 feat: 人生事件和剧本收藏从 localStorage 切换为真实 API
- 后端:新增 t_event_favorite 表(UNIQUE KEY + MyBatis-Plus Entity)
- 后端:LifeEventController 新增 /favorite/toggle、/favorite/page、/favorite/check 三个真实端点
- 后端:EventFavoriteService 完整 CRUD,含 toggle/分页/批量查询/状态检查
- 后端:删除旧的 favorite-placeholder mock 端点
- 前端:life-event/detail.vue 从 localStorage 假收藏切换为真实 API + localStorage 缓存防闪烁
- 前端:ScriptLibraryView.vue 从 localFavorites 切换为 checkFavoriteScript 真实 API
- 前端:stores/app.js 新增 toggleEventFavorite / checkEventFavorite 方法
- 修复:审核发现的 UNIQUE 约束缺失、未使用 watch 导入、Controller 参数注解遗漏、Controller 委托 Service 构造 Response

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 23:23:40 +08:00
peanut a3bfad099a docs: 收藏功能真实 API 化实施计划 2026-07-19 22:25:39 +08:00
peanut bebb7f1667 docs: 收藏功能真实 API 化设计文档 v2(沿用独立收藏表架构) 2026-07-19 22:16:15 +08:00
peanut ce9c9a8dc5 chore: 撤回旧收藏设计文档,改用独立收藏表方案 2026-07-19 22:13:17 +08:00
peanut 10d3c72082 docs: 收藏功能真实 API 化设计文档 2026-07-19 22:07:34 +08:00
peanut 05ea918ee1 feat(script-favorite): update toggleFavorite to use backend API
- Import toggleFavoriteScript from epicScript service
- Change toggleFavorite from sync local-only to async with API call
- Implement optimistic update pattern: update local cache first, then call backend
- Rollback on API failure with error toast
- Preserves localFavorites for offline/fast-render scenarios
2026-07-19 21:51:20 +08:00
peanut 50f1bdba38 feat(script-favorite): add favorite API wrappers to epicScript.js
- toggleFavoriteScript: POST /epicScript/favorite/toggle
- getFavoriteScriptPage: GET /epicScript/favorite/page
- checkFavoriteScript: GET /epicScript/favorite/check
- Added to default export object
2026-07-19 21:48:00 +08:00
peanut 74fa304cd9 feat(script-favorite): add 3 favorite endpoints to EpicScriptController
- POST /epicScript/favorite/toggle: toggle favorite for a script
- GET /epicScript/favorite/page: paginated favorite list
- GET /epicScript/favorite/check: check if script is favorited
- Add ScriptFavoriteToggleRequest, ScriptFavoriteResponse, ScriptFavoriteService imports
- Inject ScriptFavoriteService via @Autowired
2026-07-19 21:43:46 +08:00
peanut 9d352f40a0 feat(script-favorite): add ScriptFavoriteServiceImpl
- toggleFavorite: upsert via logical-delete check + save, @Transactional
- getFavoritePage: paginate favorites then fetch EpicScript details
- getFavoritedScriptIds: batch IN query for checking multiple scripts
- isFavorited: count > 0 check for single script
- getFavoriteCount: total count for current user
- Uses UserContextHolder for userId, consistent with project pattern
2026-07-19 21:41:54 +08:00
peanut 674188f471 feat(script-favorite): add ScriptFavoriteService interface
- toggleFavorite: toggle favorite status for a script
- getFavoritePage: paginated list of user's favorite scripts
- getFavoritedScriptIds: batch check which scripts are favorited
- isFavorited: check single script favorite status
- getFavoriteCount: count user's total favorites
2026-07-19 21:41:28 +08:00
peanut 62ca1730b9 feat(script-favorite): add ScriptFavoriteResponse DTO
- Fields: scriptId, isFavorited, favoriteTime
- Used by toggle/check endpoints to return favorite status
2026-07-19 21:41:14 +08:00
peanut 0d27d1a072 feat(script-favorite): add ScriptFavoriteToggleRequest DTO
- Single field: scriptId with @NotBlank validation
- Uses javax.validation.constraints.NotBlank matching project convention
2026-07-19 21:41:02 +08:00
peanut d74d8b22c6 feat(script-favorite): add ScriptFavoriteMapper
- Extends BaseMapper<ScriptFavorite>
- Standard MyBatis-Plus mapper pattern matching project convention
2026-07-19 21:40:06 +08:00
peanut 489131a04b feat(script-favorite): add ScriptFavorite entity
- Extends BaseEntity for id, createTime, updateTime, isDeleted
- Maps to t_script_favorite table via MyBatis-Plus @TableName
- Fields: userId, scriptId
2026-07-19 21:39:47 +08:00
peanut 43ac037f5b feat(script-favorite): add t_script_favorite migration SQL
- Create t_script_favorite table with user_id, script_id
- Add unique key uk_user_script (user_id, script_id)
- Add indexes idx_user_id and idx_script_id
- Support logical delete via is_deleted flag
2026-07-19 21:39:27 +08:00
peanut b20f30f91b feat(script-favorite): add database migration and backend service
- Add t_script_favorite table migration SQL
- Add ScriptFavorite entity, mapper, service interface and impl
- Add toggle/page/check endpoints in EpicScriptController
- Add toggleFavorite/getFavoritePage/checkFavorite in epicScript.js
- Update ScriptLibraryView with backend-backed toggleFavorite
2026-07-19 21:38:53 +08:00
peanut 7e52bb9ffb fix: .chat-send-btn 设 height:60rpx 与输入框等高 2026-07-19 21:04:50 +08:00
peanut 8a2001af3d docs: Chat 视图发送按钮等高修复设计文档 2026-07-19 21:04:04 +08:00
peanut 35b4f97527 fix: .chat-scroll-content padding-bottom 改回 40rpx 避免与父级 padding 叠加 2026-07-19 20:49:16 +08:00
peanut f5b26c2fc7 refactor: .chat-page 改为 height: 100% 让父级 padding 生效 2026-07-19 20:49:05 +08:00
peanut cb965fadff docs: Chat 视图底部遮挡架构层修复实施计划 2026-07-19 20:46:10 +08:00
peanut cafa8717ab docs: Chat 视图底部遮挡架构层修复设计文档 2026-07-19 20:45:09 +08:00
peanut 283914690a fix: .chat-page 增加 safe-area-inset-bottom + .chat-scroll-content padding-bottom 增至 120rpx 2026-07-19 20:21:41 +08:00
peanut 04ffa2dceb docs: Chat 视图底部内边距修复实施计划 2026-07-19 20:17:26 +08:00
peanut 0a9ea60795 docs: Chat 视图底部内边距修复设计文档 2026-07-19 19:57:57 +08:00
peanut 118d20914d fix: 修复 openScriptChat 设置 viewState='result' 导致页面空白的问题 2026-07-19 19:30:48 +08:00
peanut b194b343ad fix: 修复 mp-weixin 模板箭头函数和 TextDecoder 不兼容
- 微信小程序 wxml 模板不支持内联箭头函数
  @submit 改为方法引用,submitClarification 自己找未提交卡片
- TextDecoder 在 mp-weixin 不可用
  decodeChunk 改用纯 JS UTF-8 解码实现
2026-07-19 19:02:52 +08:00
peanut 7a0e729ab4 fix: chat-page 用 height: 100vh 替代 min-height: 100vh 修复真机滚动
根因:min-height: 100vh 允许容器超出视口增长,破坏 flex 布局,
导致 scroll-view 内部滚动层高度=内容高度(都不滚动)。

参考项目内已工作的 .result-page(height: 100% + min-height: 0)。
H5 实际验证:内容 1779px 滚动到底部后提交按钮 visibleInViewport: true
2026-07-19 18:50:57 +08:00
peanut f07817aaff fix: chat 视图 scroll-view 添加 height: 100% + min-height: 0
参考项目内已工作的 .result-view 模式:
- flex: 1 仅在 flex 布局父容器中生效
- 但 UniApp <scroll-view> 是原生组件,必须显式 height: 100%
- 同时 min-height: 0 确保 flex 子项可收缩

之前只设 flex: 1,真机上原生 scroll-view 无法计算可滚动区域。
2026-07-19 18:32:09 +08:00
peanut 30ecf9f4a3 fix: chat 视图 scroll-view 删除固定 height,改用 flex: 1 自适应真机高度
真机上状态栏/tab 栏/安全区域高度各不相同,
硬编码 calc(100vh - 220rpx) 导致 scroll-view 超出可视区域无法滚动。
2026-07-19 18:07:00 +08:00
peanut f6ef0222b3 fix: 修复 chat 视图无法滚动 + SSE 事件重复消费
- scrollResultToLatest 守卫条件兼容 chat 视图
- shortNovel.js 添加 chunkProcessed 标志位避免 H5 双重消费
2026-07-19 16:04:54 +08:00
peanut e109e59f1a feat(mini-program): sendChat 按 generationPhase 切换接口,消息进统一对话流 2026-07-19 15:24:49 +08:00
peanut e41266e6f4 feat(mini-program): 统一 chat 视图,删除 clarifying/outlining/result 独立视图 2026-07-19 15:21:43 +08:00
peanut b4fca0abfa refactor(mini-program): runGeneration+交互函数改为操作对话消息 2026-07-19 15:15:49 +08:00
peanut 84b6a77632 refactor(mini-program): handleShortNovelEvent 改为对话流追加消息 2026-07-19 15:10:42 +08:00
peanut c0adb48949 refactor(mini-program): 扩展 addResultMessage 消息模型 + 新增 generationPhase 2026-07-19 15:05:27 +08:00
peanut 25d8a9f8f2 docs:ScriptView 统一对话页改造实现计划
6 个任务:扩展消息模型、handleShortNovelEvent 改对话流、
runGeneration+交互函数改造、统一 chat 视图模板、
sendChat 接口切换、端到端浏览器验证
2026-07-19 14:55:27 +08:00
peanut b2df7d4de0 docs:ScriptView 统一对话页改造设计
将澄清/大纲/小说生成从独立视图改为统一对话流消息。
viewState 简化为 home+chat,扩展 resultMessages 消息模型
支持 kind: text/card/outline/novel,novel_done 后切换旧接口
做后续对话修改。
2026-07-19 14:45:45 +08:00
peanut 30e2836742 fix(mini-program): 修复心愿实现页接口报错 + .env 指向生产服务器
根因:.env.development 指向 localhost:19089,但本地未启动后端,
所有 API 请求 ERR_CONNECTION_REFUSED。

切换至 https://lifescript.happylifeos.com/api(生产服务器),
符合 CLAUDE.md 服务器端验收规则。

此修复与 generationDisplayText + 继续会话 一起完成完整心愿实现流程验证。
2026-07-19 14:17:11 +08:00
peanut c1293c28ac fix(mini-program): 修复心愿实现页用户消息不显示 + 支持继续会话
1. runGeneration 补赋值 generationDisplayText,修复用户输入心愿不显示
2. 识别外部服务 DAILY_GENERATION_IN_PROGRESS 错误码,保存 session_id
3. 失败态新增'继续创作'按钮,调用 followup action=retry 恢复中断会话
4. 新增 resumeSession 方法,重置状态后恢复流式生成
2026-07-19 13:58:16 +08:00
peanut d9a061a6bc fix(deploy): 使用 shlex.quote 防止 curl URL 命令注入 2026-07-19 13:27:03 +08:00
peanut e313086d9b fix(deploy): Windows 平台 curl 验证使用 NUL 替代 /dev/null
Windows cmd.exe 不支持 /dev/null(那是 Unix 概念),
导致 curl 写入失败返回 exit code 23,验证误报为'访问异常'。
改为根据 sys.platform 动态选择 null 设备。
2026-07-19 13:25:01 +08:00
peanut 1b566d574c fix(mini-program): ScriptView 模板闭合标签和 setupRecorder H5 兼容性 2026-07-19 12:56:38 +08:00
peanut 0190941df7 fix(mini-program): 短信验证码文案从 888888 改为 123456 2026-07-19 12:39:54 +08:00
peanut 34f443939e feat(mini-program): ScriptView 多阶段状态机改造 2026-07-19 12:39:50 +08:00
peanut b3a2f3a3d3 feat(mini-program): 澄清卡片组件 2026-07-19 12:36:11 +08:00
peanut ab13ac258b feat(mini-program): 短篇小说 SSE 流式服务 2026-07-19 12:36:06 +08:00
peanut 00f17d2f47 test(server): ShortNovelController 集成测试 2026-07-19 12:22:25 +08:00
peanut c06b04869b feat(server): 短篇小说 SSE 代理 Controller 2026-07-19 12:21:09 +08:00
peanut 02c58767bd fix(server): SSE 代理错误消息不再暴露原始异常信息给客户端 2026-07-19 12:18:54 +08:00
peanut 7018f5780a feat(server): 短篇小说服务 SSE 代理实现 2026-07-19 12:15:50 +08:00
peanut 031d824dd0 refactor(server): 抽取 saveNovelResult 方法供短篇小说服务复用 2026-07-19 12:08:54 +08:00
peanut 8f96239b56 style(server): 统一 ShortNovel 配置类和 DTO 的字段注释风格 2026-07-19 12:07:26 +08:00
peanut 21ce56c6da feat(server): 短篇小说外部服务配置类和请求 DTO 2026-07-19 12:02:17 +08:00
peanut ce244f9ad5 docs:短篇小说服务接口迁移实现计划
- 后端 SSE 代理层(Controller + Service + Config + DTO)
- 数据持久化 saveNovelResult 复用方法
- 前端 shortNovel.js SSE 流式服务
- ClarificationCard 组件 + ScriptView 状态机改造
- 短信验证码文案修复 + 端到端验收
2026-07-19 11:57:53 +08:00
peanut 99a3cb883e docs:短篇小说服务接口迁移设计方案
- 后端 SSE 代理 + 前端多轮交互状态机改造
- 外部 short-novel-service 接口替代内部 AI Runtime
- 数据持久化改为小说完成后一次性保存
- 短信验证码修复(888888→123456)
2026-07-19 11:52:41 +08:00
peanut 2c778fabbb refactor: ScriptView 删除 read 模式 + 兼容旧模式,统一对话流入口 2026-07-01 08:28:47 +08:00
peanut 6aeb5319e3 refactor: ScriptView 删除 read 模式,剧本入口直接进对话流 2026-07-01 07:59:48 +08:00
peanut 3bb6716e0f docs: ScriptView 简化为纯对话流模式设计文档 2026-06-30 22:45:29 +08:00
peanut 86543df75d fix: currentScriptContent 章节字段全空时用 plotJson.fullContent 作为 fallback 2026-06-30 07:45:20 +08:00
peanut 1073123360 fix: createMessage 补 userId 字段 + 使用 senderType/contentType 2026-06-29 23:52:37 +08:00
peanut 3829a3ef92 fix: createMessage 字段名改为 senderType/contentType 匹配后端 DTO 2026-06-29 23:36:26 +08:00
peanut f45fee7cce feat: chat 模式传 messageType/versionLabel/新 emits 给 MessageCard 2026-06-29 22:53:36 +08:00
peanut a713e8823c fix: loadMessages 对历史剧本 plotJson 自动初始化首条 AI 消息 2026-06-29 22:50:15 +08:00
peanut 4a7914eefe fix: displayMessages 去掉根版本过滤(chat 模式显示首条 AI 剧本) 2026-06-29 22:47:37 +08:00
peanut 88ff280b78 feat: MessageCard 支持按 messageType 条件渲染 script/chat 专属按钮组 2026-06-29 22:45:36 +08:00
peanut 912fc42860 fix: 剧本查询时自动为历史剧本补建 conversation(兼容前端临时 ID) 2026-06-29 22:40:59 +08:00
peanut 7fb90299d1 docs: ScriptView 对话模式按钮完整修复设计文档 2026-06-29 22:34:27 +08:00
peanut 6ae721eb89 fix: 回滚 ScriptView 临时 conversationId 正则保护(误伤历史剧本) 2026-06-28 23:33:29 +08:00
peanut 808a590b8d fix: nginx 根路径 301 重定向到 /emotion-museum/(修复首页 404) 2026-06-28 23:21:03 +08:00
peanut 19337b9371 fix: ScriptView loadMessages 对临时 conversationId 正则短路保护 2026-06-28 22:42:24 +08:00
peanut e3eb1afbb0 fix: listByConversation 在 conversation 不存在时返回空列表而非抛异常 2026-06-28 22:40:13 +08:00
peanut caaeb94477 feat: 增强 download-server-log.py 支持 latest/tail/errors/grep 子命令 2026-06-28 22:23:56 +08:00
peanut bd3ccbd757 test: 新增 EpicScriptDialogueService 未登录场景测试 2026-06-28 18:49:10 +08:00
peanut bf6f2797ad feat: ScriptView 改造为对话中心查看/修改模式
- 新增 scriptChat.js 和 message.js 服务导入
- 新增 viewMode、scriptId、conversationId、currentVersionMessageId、
  selectedVersionMessageId、messages、versions、chatInput 等状态
- 新增阅读模式:版本栏 + 剧本分章节展示 + 操作按钮
- 新增聊天模式:消息列表 + 聊天输入栏 + 对话修改功能
- 集成 createScriptWithDialogue、streamScriptChat、listMessagesByConversation、
  listMessageVersions、switchVersion、deleteVersion 等后端 API
- 替换 runGeneration 使用 createScriptWithDialogue 创建剧本和对话
- 替换 openScriptChat 使用后端消息加载,不再依赖 localStorage
- 移除 localStorage 作为主要消息存储(persistResultMessages、
  hydrateResultMessages、readStoredMessages、readScriptChatHistory 改为空操作)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 18:41:44 +08:00
peanut 6109202a26 chore: request.js 导出 API_BASE_URL 和 getAuthHeader 2026-06-28 18:26:30 +08:00
peanut 3b9a8222de feat: 新增 ScriptChatController 统一流式接口 2026-06-28 18:16:07 +08:00
peanut 8537c27015 feat: MessageController 新增 listByConversation、versions、deleteVersion 2026-06-28 18:09:07 +08:00
peanut a66a78a0f3 feat: EpicScriptController 新增 createWithDialogue 和 switchVersion 2026-06-28 18:09:06 +08:00
peanut 57f1e311b6 feat: 实现 EpicScriptService.switchVersion
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 18:00:27 +08:00
peanut 8618b8b80f feat: EpicScriptService 新增 switchVersion 方法声明
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 18:00:22 +08:00
peanut b11a3463b2 feat: 实现剧本消息版本管理服务
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:42:11 +08:00
peanut e252dc5fd2 feat: 新增 ScriptMessageService 接口
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:42:05 +08:00
peanut 0520f30d84 feat: 新增 ScriptChatService 接口 2026-06-28 17:36:02 +08:00
peanut 752fd30686 fix: 修复剧本对话创建服务实现问题
- 添加系统欢迎语消息(messageOrder=1, sender=system)
- 调整用户消息顺序为 messageOrder=2
- 添加 AI 助手开场白消息(messageOrder=3, sender=assistant, type=script)
- 从 AI 生成结果中解析标题并更新到剧本实体
- 修复 messageCount 为 3 以匹配实际消息数量
- 删除不再需要的 buildTitle 方法
- 新增 buildSystemWelcome 方法构建系统欢迎语

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:24:47 +08:00
peanut 56369fdeb2 feat: 新增 EpicScriptDialogueService 接口
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:15:47 +08:00
peanut 88d586bab1 feat: MessageResponse 新增 scriptId、parentMessageId、versionNumber、metadata、status、timestamp 2026-06-28 17:08:31 +08:00
peanut 0149bac281 feat: EpicScriptResponse 新增 conversationId 和 currentVersionMessageId 2026-06-28 17:08:26 +08:00
peanut 42d1350aff feat: 新增 SwitchVersionRequest 2026-06-28 17:08:22 +08:00
peanut 43622be5b0 feat: 新增 ScriptChatStreamRequest 2026-06-28 17:08:18 +08:00
peanut 74183ccb30 feat: 新增 EpicScriptCreateWithDialogueResponse 2026-06-28 17:08:13 +08:00
peanut 06b2ee58e2 feat: 新增 EpicScriptCreateWithDialogueRequest 2026-06-28 17:08:09 +08:00
peanut 83c379d5d5 feat: Message 实体新增 scriptId 和 versionNumber 字段 2026-06-28 17:01:52 +08:00
peanut 15fce9eadf feat: Conversation 实体新增 scriptId 和 currentMessageId 字段 2026-06-28 16:56:58 +08:00
peanut 0ba2960c1b feat: EpicScript 实体新增 conversationId 和 currentVersionMessageId 字段 2026-06-28 16:56:57 +08:00
peanut 0e2cc42a41 chore: 新增剧本对话关联字段迁移脚本 2026-06-28 16:50:31 +08:00
105 changed files with 20220 additions and 721 deletions
+4
View File
@@ -387,3 +387,7 @@ server/backend.err
# Windows 临时空文件
nul
# 服务器日志下载(tools/download-server-log.py 产物)
logs/server/*.log
logs/server/*.bak
+25
View File
@@ -144,6 +144,31 @@ npm run dev:mp-weixin
**开发工作流说明**:详见 [小程序开发工作流程设计](docs/superpowers/specs/2026-04-07-mini-program-dev-workflow-design.md)
#### 小程序 H5 测试登录凭证(强制默认)
所有小程序功能的 H5 验收必须使用以下测试账号,**禁止在测试中使用真实手机号或重复触发验证码接口**:
- **手机号**`19928748688`(固定测试账号,11 位中国大陆手机号格式)
- **验证码**`123456`(固定 6 位数字,生产环境短信通道返回此固定值便于测试)
**登录流程**(参考 `mini-program/src/pages/login/index.vue` + `mini-program/src/services/auth.js`):
1. 浏览器访问 `http://localhost:5180`,进入登录页
2. 在手机号输入框填写 `19928748688`
3. 点击「获取验证码」按钮,调用 `GET /api/auth/sms-code?phone=19928748688`,后端返回 `data.code: "123456"`
4. 在验证码输入框填写 `123456`
5. 点击「登录」按钮,调用 `POST /api/auth/login`,后端返回 JWT
6. Token 写入 `uni.setStorageSync('access_token', ...)`(下划线键名)
**为什么固定这两个值**
- 后端短信通道在测试模式下总是返回 `123456`,避免真实短信下发和验证码频繁失效
- `19928748688` 是项目的固定测试账号,已绑定测试数据,避免污染真实用户数据
#### 小程序页面固定端口
小程序 H5 固定端口 **5180**(参考 `前端端口管理规则`
### 一键部署
```bash
+142
View File
@@ -144,6 +144,31 @@ npm run dev:mp-weixin
**开发工作流说明**:详见 [小程序开发工作流程设计](docs/superpowers/specs/2026-04-07-mini-program-dev-workflow-design.md)
#### 小程序 H5 测试登录凭证(强制默认)
所有小程序功能的 H5 验收必须使用以下测试账号,**禁止在测试中使用真实手机号或重复触发验证码接口**:
- **手机号**`19928748688`(固定测试账号,11 位中国大陆手机号格式)
- **验证码**`123456`(固定 6 位数字,生产环境短信通道返回此固定值便于测试)
**登录流程**(参考 `mini-program/src/pages/login/index.vue` + `mini-program/src/services/auth.js`):
1. 浏览器访问 `http://localhost:5180`,进入登录页
2. 在手机号输入框填写 `19928748688`
3. 点击「获取验证码」按钮,调用 `GET /api/auth/sms-code?phone=19928748688`,后端返回 `data.code: "123456"`
4. 在验证码输入框填写 `123456`
5. 点击「登录」按钮,调用 `POST /api/auth/login`,后端返回 JWT
6. Token 写入 `uni.setStorageSync('access_token', ...)`(下划线键名)
**为什么固定这两个值**
- 后端短信通道在测试模式下总是返回 `123456`,避免真实短信下发和验证码频繁失效
- `19928748688` 是项目的固定测试账号,已绑定测试数据,避免污染真实用户数据
#### 小程序页面固定端口
小程序 H5 固定端口 **5180**(参考 `前端端口管理规则`
### 一键部署
```bash
@@ -266,6 +291,73 @@ bash deploy-all.sh admin
---
## 自动验证验收规则(最高优先级,不可违反)
**每次代码变更任务完成前,必须自动通过浏览器进行验证验收,无需用户额外要求。**
### 自动触发条件
以下任一种情况都视为"任务完成前",必须自动执行验证:
- 修改了后端 Java 代码,完成部署后,必须通过 H5/浏览器调用真实接口验证
- 修改了 mini-program 前端代码,热加载生效后,必须在浏览器中操作验证
- 修改了任意代码并声称"完成",必须走一遍受影响功能的完整体验流程
### 验证方式(按受影响端选择)
| 变更范围 | 验证方式 |
|---|---|
| 后端 Java 代码 | 部署后,通过本地 H5(连服务器 API)或 curl 验证接口 |
| mini-program(小程序) | H5 模式浏览器验证(`http://localhost:5180` |
| web 用户端 | 浏览器访问 `http://localhost:5178` 或服务器 URL |
| web-admin 管理后台 | 浏览器访问 `http://localhost:5179` 或服务器 URL |
### 禁止行为
- ❌ 禁止代码改完就直接回复"完成",不执行验证
- ❌ 禁止仅凭编译通过就认为任务结束
- ❌ 禁止等用户提醒后才去验证
- ❌ 禁止跳过浏览器 Console / Network 面板检查
### 验收通过标准
- 浏览器 Console 无**新增**错误(既有的已知兼容性警告除外)
- Network 面板中受影响的 API 返回正常(200 或业务预期状态码)
- 受影响的 UI 交互逻辑按预期工作(点击、切换、展示)
---
## 部署强制规则(最高优先级,不可违反)
**本地开发完成、验证通过、验收没有问题后,必须执行 `deploy.py` 部署脚本,将应用部署到服务器上。**
### 强制流程
每次功能开发或 bug 修复完成后,必须按以下顺序操作:
1. **本地验证**:在本地开发环境中完成功能开发和自测,确认无报错。
2. **验收通过**:按"服务器端验收强制规则"完成验收,确认功能正常。
3. **执行部署**:验收通过后,立即执行部署脚本将应用部署到服务器:
- 后端:`python deploy.py backend`
- 前端:`python deploy.py frontend`
- 管理后台:`python deploy.py admin`
- 全部:`python deploy.py all`
4. **部署验证**:部署完成后,在服务器上验证功能正常,无报错。
### 禁止行为
- ❌ 禁止本地开发完成后不部署到服务器就认为任务完成
- ❌ 禁止跳过验收直接部署
- ❌ 禁止部署后不验证就结束任务
- ❌ 禁止在编译或验收有报错的情况下部署
### 允许的唯一例外
- ✅ 纯文档修改(不涉及代码变更)
- ✅ 仅在本地运行的脚本工具(不涉及线上服务)
---
## 本地服务管理规则(强制)
**启动、重启、停止本地前后端服务时,必须使用项目根目录下的 `dev-services.py` 脚本。**
@@ -384,3 +476,53 @@ bash deploy-all.sh admin
- 关键业务操作必须记录操作日志
- 异常信息要包含足够的上下文信息
- 生产环境禁止输出 debug 级别日志
---
## 日志排查规则(强制)
**遇到"对话不存在或无权限"错误时,按以下顺序排查:**
### 1. 检查 conversationId 是否为空
- **前端检查**:在浏览器 DevTools Network 面板查看请求参数 `conversationId`
- **后端检查**:查看 `UserContextHolder.getCurrentUserId()` 是否返回有效值
- **数据库检查**:执行 `SELECT * FROM t_conversation WHERE id = '<conversationId>'` 确认对话是否存在
### 2. 检查权限匹配
- **前端问题**:当前登录用户的 userId 与对话的 userId 不一致
- **后端逻辑**`ScriptMessageServiceImpl.listByConversation` 中会校验 `conversation.getUserId().equals(currentUserId)`
- **修复方案**:确认前端传递的是正确的 conversationId,或重新创建对话
### 3. 常见原因
| 原因 | 解决方案 |
|------|----------|
| conversationId 为空字符串 | 前端增加短路保护,显示"暂无对话记录"提示 |
| 对话不存在(已删除) | 引导用户创建新对话 |
| 用户未登录 | 检查 Token 是否有效,重新登录 |
| 对话属于其他用户 | 确认前端传递的 conversationId 正确 |
### 4. 快速定位方法
使用 `tools/download-server-log.py` 脚本下载服务器日志:
```bash
# 下载完整日志
python tools/download-server-log.py latest
# 只看最近 50 条错误
python tools/download-server-log.py errors 50
# 搜索特定关键词(如 listByConversation
python tools/download-server-log.py grep "listByConversation" 20
```
日志位置:`logs/server/` 目录
### 5. 防御性编程原则
- **Service 层**:对空参数做防御性检查,返回空列表而非抛异常
- **Controller 层**:参数校验失败时返回友好错误提示
- **前端**:接口调用前做空值检查,避免发送无效请求
+33 -113
View File
@@ -1,26 +1,12 @@
# Emotion Museum HTTPS Nginx 配置
# 配置路径:/www/server/panel/vhost/nginx/emotion-museum.conf
# 域名:lifescript.happylifeos.com
# HTTP 服务器 - 强制跳转 HTTPS
server {
listen 80;
server_name lifescript.happylifeos.com;
# 强制跳转 HTTPS
return 301 https://$server_name$request_uri;
}
# HTTPS 服务器
server {
listen 443 ssl;
server_name lifescript.happylifeos.com;
# SSL 证书配置
ssl_certificate /etc/letsencrypt/live/lifescript.happylifeos.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/lifescript.happylifeos.com/privkey.pem;
# SSL 优化配置
ssl_protocols TLSv1.2 TLSv1.3;
# WeChat mini program/Cronet compatibility: keep TLS 1.2 enabled with broad modern suites.
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
@@ -29,97 +15,73 @@ server {
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security max-age=31536000 always;
# HSTS (可选,生产环境建议开启)
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# ACME 挑战目录 - 用于 SSL 证书申请和续期
location ^~ /.well-known/acme-challenge/ {
root /data/www/acme-challenge;
allow all;
try_files $uri =404;
}
# 根路径 - 用户前端应用
location = / {
return 404;
rewrite ^ /emotion-museum/ permanent;
}
location / {
location /emotion-museum/ {
alias /data/www/emotion-museum/;
autoindex off;
# 处理 Vue Router 的 history 模式
try_files $uri $uri/ /index.html;
# HTML 文件不缓存
location ~ \.html?$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# 静态资源缓存 1 年
location ~ \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# 静态资源直接返回文件,不经过 try_files
location ~* ^/emotion-museum/(assets|static)/ {
add_header Cache-Control "public, max-age=31536000, immutable";
expires 1y;
try_files $uri =404;
}
# SPA 路由兜底
try_files $uri $uri/ /emotion-museum/index.html;
}
location = /emotion-museum {
rewrite ^(.*)$ $1/ permanent;
}
# 管理后台应用路径
location /emotion-museum-admin/ {
alias /data/www/emotion-museum-admin/;
autoindex off;
# 处理 Vue Router 的 history 模式
try_files $uri $uri/ /emotion-museum-admin/index.html;
# HTML 文件不缓存
location ~ \.html?$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# 静态资源缓存 1 年
location ~ \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
expires 1y;
}
}
location = /emotion-museum-admin {
rewrite ^(.*)$ $1/ permanent;
}
# Life-Script 应用路径
location /course-of-life/ {
alias /data/www/course-of-life/;
autoindex off;
try_files $uri $uri/ /course-of-life/index.html;
}
location = /course-of-life {
rewrite ^ /course-of-life/ last;
}
# life-script React 应用
location /life-script/ {
alias /data/www/life-script/;
autoindex off;
# 处理 React Router 的 history 模式
try_files $uri $uri/ /life-script/index.html;
# HTML 文件不缓存
location ~ \.html?$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# 静态资源缓存 1 年
location ~ \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# 静态资源直接返回文件
location ~* ^/life-script/(assets|static)/ {
add_header Cache-Control "public, max-age=31536000, immutable";
expires 1y;
try_files $uri =404;
}
# SPA 路由兜底
try_files $uri $uri/ /life-script/index.html;
}
location = /life-script {
rewrite ^ /life-script/ last;
}
# 后端 API 代理
location /api {
proxy_pass http://127.0.0.1:19089;
proxy_set_header Host $host;
@@ -131,50 +93,6 @@ server {
proxy_read_timeout 60s;
}
# Swagger UI / Knife4j API 文档代理
# 使用 ^~ 前缀匹配,防止被 / 的 try_files 拦截
location ^~ /swagger-ui {
proxy_pass http://127.0.0.1:19089;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /v3/api-docs {
proxy_pass http://127.0.0.1:19089;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /doc.html {
proxy_pass http://127.0.0.1:19089;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /webjars {
proxy_pass http://127.0.0.1:19089;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Knife4j 4.x 额外需要的路径(swagger-resources 等)
location ^~ /swagger-resources {
proxy_pass http://127.0.0.1:19089;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket 代理
location /ws {
proxy_pass http://127.0.0.1:19089;
proxy_http_version 1.1;
@@ -189,21 +107,23 @@ server {
proxy_read_timeout 7d;
}
# 健康检查端点
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# 隐藏文件访问控制
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# 日志配置
access_log /www/wwwlogs/ssl-access.log;
error_log /www/wwwlogs/ssl-error.log;
}
server {
listen 80;
server_name lifescript.happylifeos.com;
return 301 https://$host$request_uri;
}
+4 -1
View File
@@ -23,6 +23,7 @@ Purpose: 统一部署所有服务到生产服务器,替代原有的多个 shel
import io
import os
import re
import shlex
import sys
import time
import subprocess
@@ -524,8 +525,10 @@ def verify_deploy():
]
for url, label in endpoints:
null_device = 'NUL' if sys.platform == 'win32' else '/dev/null'
safe_url = shlex.quote(url)
ok, stdout, _ = run_command(
f'curl -k -s -o /dev/null -w "HTTP %{{http_code}}" {url}',
f'curl -k -s -o {null_device} -w "HTTP %{{http_code}}" {safe_url}',
timeout=30
)
if ok and stdout:
@@ -0,0 +1,28 @@
---
author: AI Assistant
created_at: 2026-07-21
purpose: 记录 SSE 流式传输修复的部署验证过程
---
# SSE 流式传输修复 - 部署验证
**部署时间:** 2026-07-21
**修改内容:**
1. 后端:使用 `readUtf8LineStrict()` + 显式 flush
2. nginx:添加 `proxy_buffering off` 等配置
3. 前端:添加诊断日志(已脱敏)
**部署步骤:**
1. 编译后端:`mvn clean install -DskipTests`
2. 上传 JAR 到服务器 ✅
3. 重启服务 ✅
4. 验证 Tomcat 启动 ✅
5. 验证 SSE 接口可访问 ✅
**后续验证(需要用户手动测试):**
- [ ] 后端日志显示事件逐条到达(时间间隔 > 100ms)
- [ ] 前端 Console 显示 novel_delta 事件逐条接收
- [ ] 小说内容逐字显示
- [ ] 历史列表正确保存新生成的小说
- [ ] 数据库 t_epic_script 表有新记录
@@ -0,0 +1,30 @@
---
author: AI Assistant
created_at: 2026-07-21
purpose: 记录 nginx 配置优化,禁用 SSE 响应缓冲
---
# nginx 配置优化 - ShortNovel SSE
**文件:** `/etc/nginx/sites-enabled/lifescript.happylifeos.com.conf`
**变更内容:**
`location /api/shortNovel/` 块中使用以下配置:
- `proxy_http_version 1.1;` - 确保 SSE 长连接使用 HTTP/1.1
- `proxy_buffering off;` - 禁用代理缓冲
- `chunked_transfer_encoding on;` - 启用分块传输编码
- `proxy_set_header Connection "keep-alive";` - 保持上游连接
**原因:**
- nginx 默认启用 `proxy_buffering on`,会缓冲后端响应
- 对于 SSEServer-Sent Events)长连接,缓冲会导致事件无法立即到达前端
- 禁用缓冲后,每个事件都能立即转发给客户端,实现真正的流式传输
**验证:**
- nginx 语法检查通过:`nginx -t` → syntax is ok / test is successful
- nginx 重载成功:worker 进程已更新
- 使用 `curl -N https://lifescript.happylifeos.com/api/shortNovel/stream` 测试 SSE 流式传输
**备份文件:** `/etc/nginx/sites-enabled/lifescript.happylifeos.com.conf.backup.20260721_233252`
**管理方式:** 宝塔面板,通过 `/etc/init.d/nginx reload` 重载配置
@@ -0,0 +1,225 @@
# Chat 视图底部遮挡架构层修复实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复 ScriptView chat 视图 outline 输入框 placeholder 和修改/确认大纲按钮被主页 bottom-nav 遮挡的问题。`.chat-page``height: 100vh` 改为 `height: 100%`,让父级 `.content``padding-bottom: 132rpx`)自然生效;同时把 `.chat-scroll-content``padding-bottom` 从 120rpx 改回 40rpx,避免双重 padding。
**Architecture:** 架构层修正而非 CSS 遮罩。让 chat-page 正确填充父级已预留 bottom-nav 空间的内容区,chat 滚动区底部自然避开 bottom-nav。iPhone 安全区用 `env(safe-area-inset-bottom)` 处理。
**Tech Stack:** UniApp Vue 3 `<script setup>` + mp-weixin 编译 + rpx 单位 + `env(safe-area-inset-bottom)` 安全区适配
---
### Task 1: 修改 .chat-page 从 height: 100vh 改为 height: 100%
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue:3589-3598`
- [ ] **Step 1: 修改 .chat-page 样式**
打开 `mini-program/src/pages/main/ScriptView.vue`,定位到第 3589-3598 行的 `.chat-page` 样式块:
```css
.chat-page {
height: 100vh;
min-height: 0;
display: flex;
flex-direction: column;
background: #13091f;
box-sizing: border-box;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
```
改为(`height: 100vh``height: 100%`,删除旧版 iOS 兼容的 `constant()`):
```css
.chat-page {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: #13091f;
box-sizing: border-box;
padding-bottom: env(safe-area-inset-bottom);
}
```
- [ ] **Step 2: 提交**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "refactor: .chat-page 改为 height: 100% 让父级 padding 生效"
```
---
### Task 2: 修改 .chat-scroll-content 改回 padding-bottom: 40rpx
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue:3617-3619`
- [ ] **Step 1: 修改 .chat-scroll-content 样式**
打开 `mini-program/src/pages/main/ScriptView.vue`,定位到第 3617-3619 行的 `.chat-scroll-content` 样式块:
```css
.chat-scroll-content {
padding: 0 24rpx 120rpx;
}
```
改为(`padding-bottom` 从 120rpx 改回 40rpx,避免与父级 `.content``padding-bottom: 132rpx` 双重叠加):
```css
.chat-scroll-content {
padding: 0 24rpx 40rpx;
}
```
- [ ] **Step 2: 提交**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "fix: .chat-scroll-content padding-bottom 改回 40rpx 避免与父级 padding 叠加"
```
---
### Task 3: H5 浏览器验证修复效果
**Files:**
- Test: `http://localhost:5180` (H5 开发服务器,端口 5180 由 `dev-services.py` 管理)
- [ ] **Step 1: 确认 H5 开发服务器运行中**
```bash
python dev-services.py status
```
Expected: `mini-program` 服务在端口 5180 上运行。如未运行,执行:
```bash
python dev-services.py start mini-program
```
等待 `VITE` 编译完成,确认输出包含 `Local: http://localhost:5180/`
- [ ] **Step 2: 浏览器打开页面并进入 chat 视图**
1. 用浏览器打开 `http://localhost:5180`
2. 确认已登录(如未登录,使用手机号验证码 123456 登录)
3. 点击底部导航「爽文生成」
4. 点击任意一条灵感推荐填充输入框,点击「发送」
- [ ] **Step 3: 检查 .chat-page 计算样式**
在浏览器 DevTools Console 中执行:
```javascript
() => {
const el = document.querySelector('.chat-page');
if (!el) return 'chat-page not found';
const styles = window.getComputedStyle(el);
const parent = el.parentElement;
const parentStyles = parent ? window.getComputedStyle(parent) : null;
return {
chatPage: {
height: styles.height,
paddingBottom: styles.paddingBottom
},
parent: parent ? {
tag: parent.tagName,
className: parent.className,
paddingBottom: parentStyles.paddingBottom
} : null
};
}
```
Expected: `chatPage.height` 应小于视口高度(不再 100vh),`parent.paddingBottom` 应为 `66px` (= 132rpx @ 750rpx 设计稿/2 = 实际 132rpx / 2 = 66px in 375px viewport)。
- [ ] **Step 4: 等待 SSE 返回澄清卡片或 outline**
在 chat 视图等待 10-15 秒:
- 若出现澄清卡片(ClarificationCard):向下滚动,确认「提交」按钮在 bottom-nav 上方完整可见
- 若出现 outline(带 beats 列表):向下滚动,确认 placeholder "如需修改请输入意见" 完整可见、「修改大纲」「确认大纲」按钮在 bottom-nav 上方完整可点击
- [ ] **Step 5: 验证 chat-scroll-content 实际高度**
在 Console 中执行:
```javascript
() => {
const scrollContent = document.querySelector('.chat-scroll-content');
const scroll = document.querySelector('.chat-scroll');
if (!scrollContent || !scroll) return 'not found';
return {
scrollContentPaddingBottom: window.getComputedStyle(scrollContent).paddingBottom,
scrollHeight: scroll.offsetHeight,
scrollClientHeight: scroll.clientHeight,
scrollScrollHeight: scroll.scrollHeight
};
}
```
Expected: `scrollContentPaddingBottom` = `20px` (= 40rpx / 2 in 375px viewport)`scrollScrollHeight > scrollClientHeight` 表示有内容可滚动。
- [ ] **Step 6: 验证 home 视图不受影响**
点击 chat 视图右上角「×」关闭按钮返回 home 视图,确认:
- ✅ 首页正常渲染
- ✅ 灵感推荐、输入框、语音球布局完整
- ✅ bottom-nav 与 home 内容无重叠
---
### Task 4: mp-weixin 编译验证
**Files:**
- Build: `mini-program/unpackage/dist/build/mp-weixin/`
- [ ] **Step 1: 执行 mp-weixin 编译**
```bash
cd mini-program
npm run build:mp-weixin
```
Expected: 编译成功,无 error 输出。编译产物在 `unpackage/dist/build/mp-weixin/`
- [ ] **Step 2: 验证编译产物样式已更新**
```bash
grep -o '.chat-page[^}]*}' mini-program/unpackage/dist/build/mp-weixin/pages/main/ScriptView.wxss
grep -o '.chat-scroll-content[^}]*}' mini-program/unpackage/dist/build/mp-weixin/pages/main/ScriptView.wxss
```
Expected:
- `.chat-page` 包含 `height:100%``padding-bottom:env(safe-area-inset-bottom)`
- `.chat-page` 不再包含 `height:100vh``constant(safe-area-inset-bottom)`
- `.chat-scroll-content` 的 padding 为 `0 24rpx 40rpx`(不再是 120rpx
- [ ] **Step 3: 提交(如有微调)**
如有需要,执行:
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "style: mp-weixin 编译产物验证通过"
```
---
### Task 5: 通知用户在微信开发者工具中验证
- [ ] **Step 1: 提示用户操作**
告知用户:
> mp-weixin 编译产物已更新(`mini-program/unpackage/dist/build/mp-weixin/`)。请在**微信开发者工具**中:
> 1. 打开 `unpackage/dist/build/mp-weixin` 目录
> 2. 点击「编译」按钮重新编译
> 3. 进入心愿实现页面,触发对话流直至出现 outline 消息(带 beats 列表)
> 4. 向下滚动,确认:
> - outline 输入框的 placeholder "如需修改请输入意见" 完整可见(不被截断)
> - 「修改大纲」「确认大纲」按钮在底部导航栏上方完整可见可点击
> 5. 在 iPhone 真机/模拟器上验证底部 home indicator 区域不遮挡内容
>
> 如有问题,截图反馈。
@@ -0,0 +1,188 @@
# Chat 视图底部内边距修复实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复 ScriptView chat 视图 ClarificationCard 提交按钮被主页 bottom-nav(104rpx)遮挡导致用户无法滚动到可见的问题。
**Architecture:**`ScriptView.vue` 样式中,给 `.chat-page` 增加 `env(safe-area-inset-bottom)` 适配 iPhone 安全区,给 `.chat-scroll-content``padding-bottom` 从 40rpx 增至 120rpx,使滚动内容底部留出 bottom-nav 高度(104rpx+ 16rpx 呼吸间距。
**Tech Stack:** UniApp Vue 3 `<script setup>` + mp-weixin 编译 + rpx 单位 + `env(safe-area-inset-bottom)` 安全区适配
---
### Task 1: 修改 .chat-page 增加 iPhone 安全区内边距
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue:3589-3596`
- [ ] **Step 1: 修改 .chat-page 样式**
打开 `mini-program/src/pages/main/ScriptView.vue`,定位到第 3589-3596 行的 `.chat-page` 样式块:
```css
.chat-page {
height: 100vh;
min-height: 0;
display: flex;
flex-direction: column;
background: #13091f;
box-sizing: border-box;
}
```
改为(新增 `padding-bottom` 两行,与 `App.vue``.safe-area-bottom` 的写法保持一致,`constant()` 兼容 iOS 11.0-11.2`env()` 覆盖 iOS 11.2+):
```css
.chat-page {
height: 100vh;
min-height: 0;
display: flex;
flex-direction: column;
background: #13091f;
box-sizing: border-box;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
```
- [ ] **Step 2: 提交**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "style: .chat-page 增加 safe-area-inset-bottom 适配 iPhone 底部"
```
---
### Task 2: 修改 .chat-scroll-content 增加底部内边距
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue:3615-3617`
- [ ] **Step 1: 修改 .chat-scroll-content 样式**
打开 `mini-program/src/pages/main/ScriptView.vue`,定位到第 3615-3617 行的 `.chat-scroll-content` 样式块:
```css
.chat-scroll-content {
padding: 0 24rpx 40rpx;
}
```
改为(`padding-bottom` 从 40rpx 增至 120rpx = bottom-nav 104rpx + 16rpx 呼吸间距):
```css
.chat-scroll-content {
padding: 0 24rpx 120rpx;
}
```
- [ ] **Step 2: 提交**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "fix: .chat-scroll-content padding-bottom 增至 120rpx 避开 bottom-nav 遮挡"
```
---
### Task 3: H5 浏览器验证修复效果
**Files:**
- Test: `http://localhost:5180` (H5 开发服务器,端口 5180 由 `dev-services.py` 管理)
- [ ] **Step 1: 确认 H5 开发服务器运行中**
```bash
python dev-services.py status
```
Expected: `mini-program` 服务在端口 5180 上运行。如未运行,执行:
```bash
python dev-services.py start mini-program
```
等待 `VITE` 编译完成,确认输出包含 `Local: http://localhost:5180/`
- [ ] **Step 2: 浏览器打开页面并进入 chat 视图**
1. 用浏览器打开 `http://localhost:5180`
2. 确认已登录(如未登录,使用手机号验证码 123456 登录)
3. 点击底部导航「爽文生成」
4. 点击任意一条灵感推荐(如「我想把最近一次低谷,改写成主角觉醒的开端。」)
5. 点击「发送」按钮
- [ ] **Step 3: 验证澄清卡片提交按钮可见且可点击**
进入 chat 视图后,等待 SSE 返回澄清卡片。在浏览器中验证:
- ✅ 澄清卡片完整渲染(标题、描述、选项、自定义输入框)
- ✅ 向下滚动时「提交」按钮在 bottom-nav(「人生轨迹/爽文生成/我的」)上方,不被遮挡
- ✅ 选中一个选项后「提交」按钮变为可点击状态(不再灰色/禁用)
- ✅ 点击「提交」后流程正常继续(追加一条 user 消息气泡)
- [ ] **Step 4: 检查浏览器 Console 无报错**
打开浏览器 DevTools → Console 面板,确认:
- ✅ 无新增红色 ERROR`uni.getRecorderManager not supported` 是 H5 环境预期内的 warning,可忽略)
- ✅ Network 面板中 `/shortNovel/followup` 请求返回正常响应
- [ ] **Step 5: 验证 home 视图不受影响**
点击 chat 视图右上角「×」关闭按钮,确认:
- ✅ 返回首页(wish-home)正常渲染
- ✅ 灵感推荐、输入框、语音球布局未受 `padding-bottom` 影响
- ✅ 主页 bottom-nav 正常显示,与 home 视图内容无重叠
- [ ] **Step 6: 验证 chat-input-bar 出现时布局正确**
`generationPhase === 'done'` 时(即 novel_done 后或从历史记录打开已有剧本时):
- ✅ 底部输入栏(`chat-input-bar`)在 bottom-nav 上方显示,无重叠
- ✅ 输入栏与滚动内容之间无多余空隙
- ✅ 输入栏内的 textarea 和「发送」按钮均可正常交互
---
### Task 4: mp-weixin 编译验证
**Files:**
- Build: `mini-program/unpackage/dist/dev/mp-weixin/`
- [ ] **Step 1: 执行 mp-weixin 编译**
```bash
cd mini-program
npm run build:mp-weixin
```
Expected: 编译成功,无 error 输出。编译产物在 `unpackage/dist/dev/mp-weixin/`
- [ ] **Step 2: 验证编译产物样式已更新**
```bash
grep "padding-bottom.*env" unpackage/dist/dev/mp-weixin/pages/main/ScriptView.wxss
grep "120rpx" unpackage/dist/dev/mp-weixin/pages/main/ScriptView.wxss
```
Expected:
- `padding-bottom: env(safe-area-inset-bottom);` 出现在 `ScriptView.wxss`
- `.chat-scroll-content``padding-bottom` 值为 `120rpx`
- [ ] **Step 3: 提交**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "style: mp-weixin 编译产物验证通过"
```
---
### Task 5: 通知用户在微信开发者工具中验证
- [ ] **Step 1: 提示用户操作**
告知用户:
> mp-weixin 编译产物已更新(`unpackage/dist/dev/mp-weixin/`)。请在**微信开发者工具**中:
> 1. 打开 `unpackage/dist/dev/mp-weixin` 目录
> 2. 点击「编译」按钮重新编译
> 3. 进入心愿实现页面,选择灵感推荐 → 发送
> 4. 等待澄清卡片出现后,向下滚动,确认「提交」按钮在底部导航栏上方完整可见
> 5. 选中一个选项,点击「提交」,确认流程正常继续
> 6. 在 iPhone 真机/模拟器上验证底部 home indicator 区域不遮挡内容
>
> 如有问题,截图反馈。
@@ -0,0 +1,163 @@
# 心愿实现 chat 视图 Loading 标识修复实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复 ScriptView chat 视图在等待后端 SSE 响应期间 loading 立即消失导致页面空白的问题。
**Architecture:** 新增 computed `showChatLoading`,基于 `generationPhase === 'generating'` + `generationStatus !== 'failed'` + `resultMessages 最后一条消息 role === 'user'` 三个条件判断是否显示 loading。替换原 `v-if="generating && generationStatus !== 'failed'"`
**Tech Stack:** UniApp Vue 3 `<script setup>` + mp-weixin 编译
---
### Task 1: 新增 computed showChatLoading 并替换 v-if
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue`
- 新增 computed `showChatLoading`(在 `generationCopy` 附近,约 line 942
- 替换 line 177 的 `v-if` 条件
- [ ] **Step 1: 新增 computed showChatLoading**
打开 `mini-program/src/pages/main/ScriptView.vue`,在 line 942`generationCopy` computed 之后)新增:
```js
const showChatLoading = computed(() => {
if (generationPhase.value !== 'generating') return false
if (generationStatus.value === 'failed') return false
const lastMsg = resultMessages.value[resultMessages.value.length - 1]
// 最后一条消息是 user(刚发送的请求),说明还没收到 assistant 响应
return lastMsg && lastMsg.role === 'user'
})
```
- [ ] **Step 2: 替换 line 177 的 v-if 条件**
定位到 line 177
```html
<view v-if="generating && generationStatus !== 'failed'" class="chat-loading">
```
改为:
```html
<view v-if="showChatLoading" class="chat-loading">
```
- [ ] **Step 3: 提交**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "fix: chat 视图 loading 在等待首个 assistant 响应期间持续显示"
```
---
### Task 2: H5 浏览器验证
**Files:**
- Test: `http://localhost:5180` (H5 开发服务器,端口 5180 由 `dev-services.py` 管理)
- [ ] **Step 1: 确认 H5 开发服务器运行中**
```bash
python dev-services.py status
```
Expected: `mini-program` 服务在端口 5180 上运行。如未运行,执行:
```bash
python dev-services.py start mini-program
```
- [ ] **Step 2: 浏览器打开页面并进入 chat 视图**
1. 用浏览器打开 `http://localhost:5180`
2. 确认已登录(如未登录,使用手机号验证码 123456 登录)
3. 点击底部导航「爽文生成」
4. 在输入框输入内容(或点击灵感推荐),点击「发送」
- [ ] **Step 3: 验证 loading 立即显示**
发送后**立即**观察 chat 视图:
- ✅ 用户消息气泡出现后,下方立刻显示 loading 动画(loading-orbit + loading-copy 文本,如 "正在把你的心愿写成故事")
- ✅ loading 不是空白,也不是瞬间消失
- ✅ 浏览器 Network 面板能看到 SSE 请求正在进行
- [ ] **Step 4: 验证 loading 在收到首个 assistant 响应后隐藏**
等待 SSE 响应(通常 3-10 秒):
- ✅ 收到 `clarification_card` 后,澄清卡片出现,loading 自动隐藏
- ✅ 或直接收到 `outline_created` 后,大纲出现,loading 自动隐藏
- ✅ 或直接收到 `novel_start` 后,小说内容开始流式出现,loading 自动隐藏
- [ ] **Step 5: 验证澄清回答后 loading 重新显示**
如果出现澄清卡片:
1. 选择一个选项,点击「提交」
2. ✅ 用户回答作为消息追加后,loading 立即重新显示
3. ✅ 等待下一个 assistant 响应(outline_created / novel_start
4. ✅ 响应到达后,loading 自动隐藏
- [ ] **Step 6: 检查浏览器 Console 无报错**
打开浏览器 DevTools → Console 面板,确认:
- ✅ 无新增红色 ERROR`uni.getRecorderManager not supported` 是 H5 环境预期内的 warning,可忽略)
- [ ] **Step 7: 验证失败状态不显示 loading**
如果生成失败(网络异常或后端错误):
- ✅ loading 不显示
- ✅ 显示「继续创作」按钮(resume 按钮)
---
### Task 3: mp-weixin 编译验证
**Files:**
- Build: `mini-program/unpackage/dist/build/mp-weixin/`
- [ ] **Step 1: 执行 mp-weixin 编译**
```bash
cd mini-program
npm run build:mp-weixin
```
Expected: 编译成功,无 error 输出。编译产物在 `unpackage/dist/build/mp-weixin/`
- [ ] **Step 2: 验证编译产物**
```bash
grep "showChatLoading" mini-program/unpackage/dist/build/mp-weixin/pages/main/ScriptView.wxml
```
Expected: 编译产物中 `.chat-loading``v-if` 使用了 `showChatLoading`(或编译后的等效表达式),不再使用原来的 `generating && generationStatus !== 'failed'`
- [ ] **Step 3: 提交(如有微调)**
如有需要,执行:
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "style: mp-weixin 编译产物验证通过"
```
---
### Task 4: 通知用户在微信开发者工具中验证
- [ ] **Step 1: 提示用户操作**
告知用户:
> mp-weixin 编译产物已更新(`mini-program/unpackage/dist/build/mp-weixin/`)。请在**微信开发者工具**中:
> 1. 打开 `unpackage/dist/build/mp-weixin` 目录
> 2. 点击「编译」按钮重新编译
> 3. 进入心愿实现页面,发送一条消息
> 4. 观察等待 SSE 响应期间是否有 loading 动画持续显示(loading-orbit 动画 + "正在把你的心愿写成故事" 等文案)
> 5. 等待澄清卡片 / 大纲 / 小说内容出现后,确认 loading 自动隐藏
> 6. 如有澄清问题流程,回答后确认 loading 重新显示直到下一个响应到达
>
> 如有问题,截图反馈。
@@ -0,0 +1,183 @@
# 澄清卡片已回答显示修复实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复澄清卡片提交后"已回答:xxx"显示英文 value 而非中文 label,以及已回答状态不显示 AI 问题的问题。
**Architecture:**`ScriptView.vue``submitClarification` 函数中,通过 value 查找 `card.options` 对应的 label,用 label 显示(`card.answer``addResultMessage`),后端 payload 仍用 value。在 `card-answered` 模板中显示 AI 的问题(`msg.card?.question`)。
**Tech Stack:** UniApp Vue 3 `<script setup>` + mp-weixin 编译
---
### Task 1: 修改 submitClarification 用 label 显示
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue`
- 修改 `submitClarification` 函数(约 line 1746-1764
- [ ] **Step 1: 修改 submitClarification**
打开 `mini-program/src/pages/main/ScriptView.vue`,定位到 `submitClarification` 函数(约 line 1746-1764)。
替换为:
```js
const submitClarification = (answer) => {
if (answeringClarification.value) return
// 找最后一张未提交的卡片消息(mp-weixin 模板不支持内联箭头函数传 msg,改为自己查找)
const card = [...resultMessages.value].reverse().find(m => m.kind === 'card' && !m.submitted)
// 通过 value 查找选项的 label,用于前端显示;后端 payload 仍用 value(语义化)
const option = card?.card?.options?.find(opt => opt.value === answer)
const label = option?.label || answer
if (card) {
card.submitted = true
card.answer = label // 显示用 label(中文)
}
addResultMessage({ role: 'user', kind: 'text', content: label })
answeringClarification.value = true
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'answer_clarification',
payload: { answer }, // 后端仍收 value
onEvent: handleShortNovelEvent,
onError: (errMsg) => markGenerationFailed(errMsg)
})
setTimeout(() => { answeringClarification.value = false }, 200)
}
```
- [ ] **Step 2: 提交**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "fix: 澄清卡片已回答用 label 显示,后端 payload 仍用 value"
```
---
### Task 2: 修改 card-answered 模板显示 AI 的问题
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue`
- 修改 `card-answered` 模板(约 line 131-133
- 新增 `.card-question-text` 样式(在 `.card-answered` 样式附近)
- [ ] **Step 1: 修改 card-answered 模板**
定位到约 line 131-133
```html
<view v-else class="card-answered">
<text>已回答:{{ msg.answer }}</text>
</view>
```
替换为:
```html
<view v-else class="card-answered">
<text v-if="msg.card?.question" class="card-question-text">{{ msg.card.question }}</text>
<text>已回答:{{ msg.answer }}</text>
</view>
```
- [ ] **Step 2: 新增 .card-question-text 样式**
`.card-answered` 样式块附近(用 Grep 找 `.card-answered {` 位置),新增样式:
```css
.card-question-text {
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
margin-bottom: 16rpx;
line-height: 1.5;
display: block;
}
```
- [ ] **Step 3: 提交**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "fix: 已回答状态显示 AI 的问题(question"
```
---
### Task 3: H5 浏览器验证
**Files:**
- Test: `http://localhost:5180` (H5 开发服务器)
- [ ] **Step 1: 确认 H5 开发服务器运行中**
```bash
python dev-services.py status
```
如未运行:`python dev-services.py start mini-program`
- [ ] **Step 2: 浏览器打开页面并进入 chat 视图**
1. 打开 `http://localhost:5180`
2. 登录(如未登录,用 123456 验证码)
3. 点击「爽文生成」→ 输入内容 → 点击「发送」
- [ ] **Step 3: 验证澄清卡片提交后的显示**
等待澄清卡片出现:
1. 选择一个选项(如"工作/学习上的事"),点击「提交」
2. 确认:
- ✅ 澄清卡片位置显示 AI 的问题(如"你想重写今天的什么经历?")
- ✅ "已回答:"后面显示中文 label(如"工作/学习上的事"),而非英文 value(如"competition"
- ✅ 下方用户气泡显示中文 label(如"工作/学习上的事"
- [ ] **Step 4: 检查浏览器 Console 无报错**
打开 DevTools → Console 面板,确认:
- ✅ 无新增红色 ERROR(预先存在的 favicon 404、`uni.getRecorderManager not supported` 可忽略)
---
### Task 4: mp-weixin 编译验证
**Files:**
- Build: `mini-program/unpackage/dist/build/mp-weixin/`
- [ ] **Step 1: 执行 mp-weixin 编译**
```bash
cd mini-program
npm run build:mp-weixin
```
Expected: 编译成功,无 error 输出。
- [ ] **Step 2: 提交(如有微调)**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "style: mp-weixin 编译产物验证通过"
```
---
### Task 5: 通知用户在微信开发者工具中验证
- [ ] **Step 1: 提示用户操作**
告知用户:
> mp-weixin 编译产物已更新。请在**微信开发者工具**中:
> 1. 打开 `unpackage/dist/build/mp-weixin` 目录
> 2. 点击「编译」重新编译
> 3. 进入心愿实现页面,发送一条消息
> 4. 等待澄清卡片出现,选择一个选项,点击「提交」
> 5. 确认:
> - 已回答状态显示 AI 的问题(如"你想重写今天的什么经历?")
> - "已回答:"后面显示中文 label(如"工作/学习上的事"),而非英文 value
> - 用户气泡显示中文 label
>
> 如有问题,截图反馈。
@@ -0,0 +1,939 @@
---
author: AI Assistant
created_at: 2026-07-19
purpose: 小程序收藏功能真实 API 化实施计划
---
# 收藏功能真实 API 化实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 前端收藏功能从 localStorage 切换到真实 API。剧本收藏沿用现有后端 API,人生事件收藏新建独立表和 API。
**Architecture:** 剧本收藏后端已完整(`t_script_favorite` 表 + `ScriptFavoriteService`),前端 `ScriptLibraryView.vue` 未对接。人生事件收藏仿照剧本,新建 `t_event_favorite` 表 + `EventFavoriteService` + Controller,删除假接口 `/lifeEvent/favorite-placeholder`。前端所有 localStorage 收藏逻辑切换到 API。
**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus 3.5.3.1 + UniApp Vue 3 + Pinia + Element Plus + rpx
---
### Task 1: 后端人生事件收藏功能(Entity + Mapper + Service + Controller
**Files:**
- Create: `server/src/main/resources/db/migration/V20260719000002__create_event_favorite.sql`
- Create: `server/src/main/java/com/emotion/entity/EventFavorite.java`
- Create: `server/src/main/java/com/emotion/mapper/EventFavoriteMapper.java`
- Create: `server/src/main/java/com/emotion/dto/request/EventFavoriteToggleRequest.java`
- Create: `server/src/main/java/com/emotion/dto/response/EventFavoriteResponse.java`
- Create: `server/src/main/java/com/emotion/service/EventFavoriteService.java`
- Create: `server/src/main/java/com/emotion/service/impl/EventFavoriteServiceImpl.java`
- Modify: `server/src/main/java/com/emotion/controller/LifeEventController.java`
- [ ] **Step 1: 创建数据库迁移 SQL**
```sql
-- server/src/main/resources/db/migration/V20260719000002__create_event_favorite.sql
CREATE TABLE t_event_favorite (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
event_id VARCHAR(64) NOT NULL COMMENT '事件ID',
create_by VARCHAR(64) COMMENT '创建人',
create_time DATETIME COMMENT '创建时间',
update_by VARCHAR(64) COMMENT '更新人',
update_time DATETIME COMMENT '更新时间',
is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除:0-否 1-是',
remarks VARCHAR(500) COMMENT '备注',
INDEX idx_user_event (user_id, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人生事件收藏关联表';
```
- [ ] **Step 2: 创建 EventFavorite 实体类**
```java
// server/src/main/java/com/emotion/entity/EventFavorite.java
package com.emotion.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.emotion.common.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
/**
* 人生事件收藏关联实体
*
* @author AI Assistant
* @date 2026-07-19
*/
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_event_favorite")
public class EventFavorite extends BaseEntity {
@TableField("user_id")
private String userId;
@TableField("event_id")
private String eventId;
}
```
- [ ] **Step 3: 创建 EventFavoriteMapper**
```java
// server/src/main/java/com/emotion/mapper/EventFavoriteMapper.java
package com.emotion.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotion.entity.EventFavorite;
import org.apache.ibatis.annotations.Mapper;
/**
* 人生事件收藏 Mapper
*
* @author AI Assistant
* @date 2026-07-19
*/
@Mapper
public interface EventFavoriteMapper extends BaseMapper<EventFavorite> {
}
```
- [ ] **Step 4: 创建 EventFavoriteToggleRequest DTO**
```java
// server/src/main/java/com/emotion/dto/request/EventFavoriteToggleRequest.java
package com.emotion.dto.request;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 人生事件收藏切换请求
*
* @author AI Assistant
* @date 2026-07-19
*/
@Data
public class EventFavoriteToggleRequest {
@NotBlank(message = "事件ID不能为空")
private String eventId;
}
```
- [ ] **Step 5: 创建 EventFavoriteResponse DTO**
```java
// server/src/main/java/com/emotion/dto/response/EventFavoriteResponse.java
package com.emotion.dto.response;
import lombok.Data;
/**
* 人生事件收藏状态响应
*
* @author AI Assistant
* @date 2026-07-19
*/
@Data
public class EventFavoriteResponse {
private String eventId;
private Boolean isFavorited;
private String favoriteTime;
}
```
- [ ] **Step 6: 创建 EventFavoriteService 接口**
```java
// server/src/main/java/com/emotion/service/EventFavoriteService.java
package com.emotion.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.emotion.common.PageResult;
import com.emotion.dto.response.EventFavoriteResponse;
import com.emotion.dto.response.LifeEventResponse;
import com.emotion.entity.EventFavorite;
import java.util.Set;
/**
* 人生事件收藏服务接口
*
* @author AI Assistant
* @date 2026-07-19
*/
public interface EventFavoriteService extends IService<EventFavorite> {
EventFavoriteResponse toggleFavorite(String eventId);
PageResult<LifeEventResponse> getFavoritePage(long current, long size);
Set<String> getFavoritedEventIds(Set<String> eventIds);
boolean isFavorited(String eventId);
long getFavoriteCount();
}
```
- [ ] **Step 7: 创建 EventFavoriteServiceImpl 实现**
```java
// server/src/main/java/com/emotion/service/impl/EventFavoriteServiceImpl.java
package com.emotion.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.common.PageResult;
import com.emotion.dto.response.EventFavoriteResponse;
import com.emotion.dto.response.LifeEventResponse;
import com.emotion.entity.EventFavorite;
import com.emotion.mapper.EventFavoriteMapper;
import com.emotion.service.EventFavoriteService;
import com.emotion.service.LifeEventService;
import com.emotion.util.UserContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
/**
* 人生事件收藏服务实现
*
* @author AI Assistant
* @date 2026-07-19
*/
@Slf4j
@Service
public class EventFavoriteServiceImpl extends ServiceImpl<EventFavoriteMapper, EventFavorite>
implements EventFavoriteService {
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Autowired
private LifeEventService lifeEventService;
@Override
@Transactional(rollbackFor = Exception.class)
public EventFavoriteResponse toggleFavorite(String eventId) {
String userId = UserContextHolder.getCurrentUserId();
EventFavoriteResponse r = new EventFavoriteResponse();
r.setEventId(eventId);
if (!StringUtils.hasText(userId)) {
r.setIsFavorited(false);
return r;
}
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
w.eq(EventFavorite::getUserId, userId)
.eq(EventFavorite::getEventId, eventId)
.eq(EventFavorite::getIsDeleted, 0);
EventFavorite existing = this.getOne(w);
if (existing != null) {
this.removeById(existing.getId());
r.setIsFavorited(false);
} else {
EventFavorite fav = new EventFavorite();
fav.setUserId(userId);
fav.setEventId(eventId);
this.save(fav);
r.setIsFavorited(true);
if (fav.getCreateTime() != null) {
r.setFavoriteTime(fav.getCreateTime().format(FMT));
}
}
return r;
}
@Override
public PageResult<LifeEventResponse> getFavoritePage(long current, long size) {
String userId = UserContextHolder.getCurrentUserId();
if (!StringUtils.hasText(userId)) {
return new PageResult<>();
}
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
w.eq(EventFavorite::getUserId, userId)
.eq(EventFavorite::getIsDeleted, 0)
.orderByDesc(EventFavorite::getCreateTime);
Page<EventFavorite> page = new Page<>(current, size);
Page<EventFavorite> result = this.page(page, w);
List<LifeEventResponse> records = result.getRecords().stream()
.map(EventFavorite::getEventId)
.map(lifeEventService::getEventById)
.filter(Objects::nonNull)
.collect(Collectors.toList());
PageResult<LifeEventResponse> pr = new PageResult<>();
pr.setCurrent(result.getCurrent());
pr.setSize(result.getSize());
pr.setTotal(result.getTotal());
pr.setPages(result.getPages());
pr.setRecords(records);
return pr;
}
@Override
public Set<String> getFavoritedEventIds(Set<String> eventIds) {
String userId = UserContextHolder.getCurrentUserId();
if (!StringUtils.hasText(userId) || eventIds == null || eventIds.isEmpty()) {
return Collections.emptySet();
}
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
w.eq(EventFavorite::getUserId, userId)
.in(EventFavorite::getEventId, eventIds)
.eq(EventFavorite::getIsDeleted, 0);
return this.list(w).stream()
.map(EventFavorite::getEventId)
.collect(Collectors.toSet());
}
@Override
public boolean isFavorited(String eventId) {
String userId = UserContextHolder.getCurrentUserId();
if (!StringUtils.hasText(userId) || !StringUtils.hasText(eventId)) {
return false;
}
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
w.eq(EventFavorite::getUserId, userId)
.eq(EventFavorite::getEventId, eventId)
.eq(EventFavorite::getIsDeleted, 0);
return this.count(w) > 0;
}
@Override
public long getFavoriteCount() {
String userId = UserContextHolder.getCurrentUserId();
if (!StringUtils.hasText(userId)) {
return 0L;
}
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
w.eq(EventFavorite::getUserId, userId)
.eq(EventFavorite::getIsDeleted, 0);
return this.count(w);
}
}
```
- [ ] **Step 8: 修改 LifeEventController 添加收藏 API**
`server/src/main/java/com/emotion/controller/LifeEventController.java` 中添加:
1. 在类顶部添加注入:
```java
@Autowired
private EventFavoriteService eventFavoriteService;
```
2. 添加 import
```java
import com.emotion.dto.request.EventFavoriteToggleRequest;
import com.emotion.dto.response.EventFavoriteResponse;
import com.emotion.service.EventFavoriteService;
```
3. 在类末尾(`readTags` 方法之前)添加收藏 API
```java
// ==================== 人生事件收藏 ====================
@Operation(summary = "切换收藏", description = "收藏或取消收藏指定事件。")
@PostMapping(value = "/favorite/toggle")
public Result<EventFavoriteResponse> toggleFavorite(
@Valid @RequestBody EventFavoriteToggleRequest request) {
return Result.success(eventFavoriteService.toggleFavorite(request.getEventId()));
}
@Operation(summary = "分页查询收藏", description = "分页查询当前用户收藏的事件列表。")
@GetMapping(value = "/favorite/page")
public Result<PageResult<LifeEventResponse>> getFavoritePage(
@RequestParam(defaultValue = "1") long current,
@RequestParam(defaultValue = "10") long size) {
return Result.success(eventFavoriteService.getFavoritePage(current, size));
}
@Operation(summary = "检查收藏状态", description = "检查指定事件是否已被当前用户收藏。")
@GetMapping(value = "/favorite/check")
public Result<EventFavoriteResponse> checkFavorite(@RequestParam String eventId) {
boolean favorited = eventFavoriteService.isFavorited(eventId);
EventFavoriteResponse r = new EventFavoriteResponse();
r.setEventId(eventId);
r.setIsFavorited(favorited);
return Result.success(r);
}
```
4. **删除** `/lifeEvent/favorite-placeholder` 假接口(第 153-163 行):
```java
// 删除以下方法
@Operation(summary = "收藏占位接口", description = "返回收藏功能的占位响应。")
@PostMapping(value = "/favorite-placeholder")
public Result<Map<String, Object>> favoritePlaceholder(@RequestBody Map<String, Object> request) {
// ... 整段删除
}
```
- [ ] **Step 9: 提交**
```bash
git add server/src/main/resources/db/migration/V20260719000002__create_event_favorite.sql
git add server/src/main/java/com/emotion/entity/EventFavorite.java
git add server/src/main/java/com/emotion/mapper/EventFavoriteMapper.java
git add server/src/main/java/com/emotion/dto/request/EventFavoriteToggleRequest.java
git add server/src/main/java/com/emotion/dto/response/EventFavoriteResponse.java
git add server/src/main/java/com/emotion/service/EventFavoriteService.java
git add server/src/main/java/com/emotion/service/impl/EventFavoriteServiceImpl.java
git add server/src/main/java/com/emotion/controller/LifeEventController.java
git commit -m "feat: 新增人生事件收藏功能(独立收藏表 + API)"
```
---
### Task 2: 后端编译 + 部署到服务器
**Files:**
- Build: `server/target/`
- Deploy: 服务器
- [ ] **Step 1: 本地编译验证**
```bash
cd server
mvn clean install -DskipTests
```
Expected: BUILD SUCCESS,无编译错误。
- [ ] **Step 2: 部署到服务器**
```bash
cd ..
python deploy.py backend
```
Expected: 部署成功,服务器自动重启。
- [ ] **Step 3: 验证部署**
```bash
python deploy.py verify
```
Expected: 后端服务运行正常。
- [ ] **Step 4: 提交**
```bash
git commit -m "chore: 后端人生事件收藏功能已部署" --allow-empty
```
---
### Task 3: 前端 ScriptLibraryView.vue 切换到剧本收藏 API
**Files:**
- Modify: `mini-program/src/pages/main/ScriptLibraryView.vue`
- [ ] **Step 1: 删除 localFavorites ref,改用内存状态**
删除第 160 行:
```javascript
// 删除这一行
const localFavorites = ref(uni.getStorageSync('script_favorites') || {})
```
替换为:
```javascript
const favoriteStatus = ref({})
```
- [ ] **Step 2: 修改 isFavorite 计算**
修改第 245-247 行:
```javascript
// 之前:
const isFavorite = (script) => {
return Boolean(script.isFavorite || script.favorite || localFavorites.value[String(script.id)])
}
// 之后:
const isFavorite = (script) => {
return Boolean(favoriteStatus.value[String(script.id)])
}
```
- [ ] **Step 3: 修改 toggleFavorite,删除 localStorage 写**
修改第 320-348 行:
```javascript
// 之前:
const toggleFavorite = async (script) => {
const wasFav = isFavorite(script)
// 乐观更新:先改本地缓存
const next = { ...localFavorites.value }
if (wasFav) {
delete next[String(script.id)]
} else {
next[String(script.id)] = true
}
localFavorites.value = next
uni.setStorageSync('script_favorites', next)
closeScriptMenu()
try {
const result = await toggleFavoriteScript(script.id)
uni.showToast({ title: result.isFavorited ? '已收藏' : '已取消收藏', icon: 'success' })
} catch (error) {
// 回滚乐观更新
const restored = { ...localFavorites.value }
if (wasFav) {
restored[String(script.id)] = true
} else {
delete restored[String(script.id)]
}
localFavorites.value = restored
uni.setStorageSync('script_favorites', restored)
uni.showToast({ title: error?.message || '操作失败', icon: 'none' })
}
}
// 之后:
const toggleFavorite = async (script) => {
const wasFav = isFavorite(script)
// 乐观更新:先改内存状态
const next = { ...favoriteStatus.value }
if (wasFav) {
delete next[String(script.id)]
} else {
next[String(script.id)] = true
}
favoriteStatus.value = next
closeScriptMenu()
try {
const result = await toggleFavoriteScript(script.id)
uni.showToast({ title: result.isFavorited ? '已收藏' : '已取消收藏', icon: 'success' })
} catch (error) {
// 回滚乐观更新
const restored = { ...favoriteStatus.value }
if (wasFav) {
restored[String(script.id)] = true
} else {
delete restored[String(script.id)]
}
favoriteStatus.value = restored
uni.showToast({ title: error?.message || '操作失败', icon: 'none' })
}
}
```
- [ ] **Step 4: 修改 confirmDeleteScript,删除 localStorage 清理**
修改第 386-389 行:
```javascript
// 之前:
const next = { ...localFavorites.value }
delete next[id]
localFavorites.value = next
uni.setStorageSync('script_favorites', next)
// 之后:
const next = { ...favoriteStatus.value }
delete next[id]
favoriteStatus.value = next
```
- [ ] **Step 5: 列表加载后批量检查收藏状态**
`visibleScripts` computed 后添加 watch 或 onMounted 钩子,批量调用 `checkFavoriteScript`
在文件顶部添加 import
```javascript
import { checkFavoriteScript } from '../../services/epicScript.js'
```
`visibleScripts` computed 后添加:
```javascript
// 列表加载后批量检查收藏状态
const loadFavoriteStatus = async () => {
const ids = scripts.value.map(s => String(s.id)).filter(id => !id.startsWith('demo-'))
if (!ids.length) return
const status = {}
await Promise.all(ids.map(async (id) => {
try {
status[id] = await checkFavoriteScript(id)
} catch {
status[id] = false
}
}))
favoriteStatus.value = status
}
// 监听 scripts 变化时重新加载收藏状态
watch(scripts, () => {
loadFavoriteStatus()
}, { immediate: true })
```
确保 import watch
```javascript
import { computed, ref, watch } from 'vue'
```
- [ ] **Step 6: 提交**
```bash
git add mini-program/src/pages/main/ScriptLibraryView.vue
git commit -m "refactor: 剧本收藏从 localStorage 切换到真实 API"
```
---
### Task 4: 前端 life-event 切换到人生事件收藏 API
**Files:**
- Modify: `mini-program/src/services/lifeEvent.js`
- Modify: `mini-program/src/pages/life-event/detail.vue`
- Modify: `mini-program/src/stores/app.js`
- [ ] **Step 1: 修改 lifeEvent.js 新增收藏 API 封装**
`mini-program/src/services/lifeEvent.js` 中:
1. **删除**旧的 `favoriteEvent` 方法(第 67-69 行):
```javascript
// 删除:
export const favoriteEvent = async ({ id, favorite }) => {
return post('/lifeEvent/favorite-placeholder', { id, favorite })
}
```
2. **新增**真实的收藏 API
```javascript
/**
* 切换人生事件收藏状态(收藏/取消收藏)
* @param {string} eventId 事件ID
* @returns {Promise<{success: boolean, isFavorited: boolean}>}
*/
export const toggleFavoriteEvent = async (eventId) => {
const res = await post('/lifeEvent/favorite/toggle', { eventId })
return {
success: true,
isFavorited: res.data?.isFavorited ?? false,
favoriteTime: res.data?.favoriteTime ?? null
}
}
/**
* 检查人生事件是否已收藏
* @param {string} eventId 事件ID
* @returns {Promise<boolean>}
*/
export const checkFavoriteEvent = async (eventId) => {
const res = await get('/lifeEvent/favorite/check', { eventId })
return res.data?.isFavorited ?? false
}
```
3. 在 default export 中更新方法列表:
```javascript
export default {
getEventList,
getEventPage,
getEventById,
createEvent,
updateEvent,
deleteEvent,
assistEventWriting,
chatAboutEvent,
shareEvent,
toggleFavoriteEvent,
checkFavoriteEvent,
transformToFrontendFormat,
transformListToFrontend
}
```
- [ ] **Step 2: 修改 stores/app.js 的 favoriteEvent action**
`mini-program/src/stores/app.js` 中:
1. **删除**旧的 `favoriteEvent` action(第 235-242 行):
```javascript
// 删除:
const favoriteEvent = async ({ id, favorite }) => {
try {
const res = await lifeEventService.favoriteEvent({ id, favorite })
return { success: true, data: res.data }
} catch (error) {
return { success: false, error: error.message }
}
}
```
2. **新增**新的 action
```javascript
const toggleEventFavorite = async (eventId) => {
try {
const result = await lifeEventService.toggleFavoriteEvent(eventId)
return { success: true, data: result }
} catch (error) {
return { success: false, error: error.message }
}
}
const checkEventFavorite = async (eventId) => {
try {
const isFavorited = await lifeEventService.checkFavoriteEvent(eventId)
return { success: true, data: isFavorited }
} catch (error) {
return { success: false, error: error.message }
}
}
```
3. 在 return 对象中更新导出:
```javascript
return {
// ... 其他方法
toggleEventFavorite,
checkEventFavorite,
// 删除 favoriteEvent
}
```
- [ ] **Step 3: 修改 detail.vue 初始化时检查收藏状态**
`mini-program/src/pages/life-event/detail.vue` 中:
1. 修改 `onMounted`(第 137-149 行):
```javascript
// 之前:
onMounted(async () => {
const info = uni.getWindowInfo()
safeAreaBottom.value = info.safeAreaInsets?.bottom || 0
const pages = getCurrentPages()
eventId.value = pages[pages.length - 1]?.options?.id || ''
cachedEvent.value = uni.getStorageSync('current_life_event') || null
if (!store.events?.length) await store.fetchEvents()
isFavorite.value = Boolean(uni.getStorageSync(`event_favorite_${eventId.value}`))
analytics.trackPageView(pagePath, {
life_event_id: eventId.value,
tag_count: Array.isArray(displayEvent.value.tags) ? displayEvent.value.tags.length : 0
})
})
// 之后:
onMounted(async () => {
const info = uni.getWindowInfo()
safeAreaBottom.value = info.safeAreaInsets?.bottom || 0
const pages = getCurrentPages()
eventId.value = pages[pages.length - 1]?.options?.id || ''
cachedEvent.value = uni.getStorageSync('current_life_event') || null
if (!store.events?.length) await store.fetchEvents()
// 从 API 检查收藏状态
const result = await store.checkEventFavorite(eventId.value)
if (result.success) {
isFavorite.value = result.data
}
analytics.trackPageView(pagePath, {
life_event_id: eventId.value,
tag_count: Array.isArray(displayEvent.value.tags) ? displayEvent.value.tags.length : 0
})
})
```
- [ ] **Step 4: 修改 detail.vue 的 toggleFavorite,删除 localStorage 写**
修改第 306-322 行:
```javascript
// 之前:
const toggleFavorite = async () => {
if (!eventId.value) return
const next = !isFavorite.value
const result = await store.favoriteEvent({ id: eventId.value, favorite: next })
if (!result.success) {
uni.showToast({ title: result.error || '收藏失败', icon: 'none' })
return
}
isFavorite.value = next
if (next) uni.setStorageSync(`event_favorite_${eventId.value}`, '1')
else uni.removeStorageSync(`event_favorite_${eventId.value}`)
analytics.track('life_event_favorite', {
life_event_id: eventId.value,
favorite: next
}, { eventType: 'life_event', pagePath })
uni.showToast({ title: next ? '已收藏' : '已取消收藏', icon: 'success' })
}
// 之后:
const toggleFavorite = async () => {
if (!eventId.value) return
const result = await store.toggleEventFavorite(eventId.value)
if (!result.success) {
uni.showToast({ title: result.error || '收藏失败', icon: 'none' })
return
}
isFavorite.value = result.data?.isFavorited ?? false
analytics.track('life_event_favorite', {
life_event_id: eventId.value,
favorite: isFavorite.value
}, { eventType: 'life_event', pagePath })
uni.showToast({ title: isFavorite.value ? '已收藏' : '已取消收藏', icon: 'success' })
}
```
- [ ] **Step 5: 提交**
```bash
git add mini-program/src/services/lifeEvent.js
git add mini-program/src/stores/app.js
git add mini-program/src/pages/life-event/detail.vue
git commit -m "refactor: 人生事件收藏从 localStorage 切换到真实 API"
```
---
### Task 5: H5 浏览器验证
**Files:**
- Test: `http://localhost:5180` (H5 开发服务器)
- [ ] **Step 1: 确认 H5 开发服务器运行中**
```bash
python dev-services.py status
```
Expected: `mini-program` 服务在端口 5180 上运行。如未运行,执行:
```bash
python dev-services.py start mini-program
```
- [ ] **Step 2: 浏览器打开页面并登录**
1. 打开 `http://localhost:5180`
2. 如未登录,使用手机号验证码 123456 登录
- [ ] **Step 3: 验证剧本收藏**
1. 点击底部导航「爽文生成」
2. 点击「剧本库」
3. 点击任意剧本的收藏图标(心形)
4. 确认:
- ✅ Toast 提示「已收藏」
- ✅ 心形图标变为实心
- ✅ Network 面板中有 `POST /epicScript/favorite/toggle` 请求,返回 `isFavorited: true`
5. 刷新页面
6. 确认收藏状态仍保持(心形图标仍为实心)
7. 再次点击收藏图标取消收藏
8. 确认:
- ✅ Toast 提示「已取消收藏」
- ✅ 心形图标变为空心
- ✅ Network 面板中有 `POST /epicScript/favorite/toggle` 请求,返回 `isFavorited: false`
- [ ] **Step 4: 验证人生事件收藏**
1. 点击底部导航「人生轨迹」
2. 点击任意事件进入详情页
3. 点击收藏按钮
4. 确认:
- ✅ Toast 提示「已收藏」
- ✅ 收藏按钮状态变化
- ✅ Network 面板中有 `POST /lifeEvent/favorite/toggle` 请求,返回 `isFavorited: true`
5. 返回列表页,再次进入同一事件详情
6. 确认收藏状态仍保持
7. 再次点击收藏按钮取消收藏
8. 确认:
- ✅ Toast 提示「已取消收藏」
- ✅ 收藏按钮状态恢复
- ✅ Network 面板中有 `POST /lifeEvent/favorite/toggle` 请求,返回 `isFavorited: false`
- [ ] **Step 5: 检查浏览器 Console 无报错**
打开浏览器 DevTools → Console 面板,确认:
- ✅ 无红色 ERROR
- ✅ 无 localStorage 相关的 `script_favorites``event_favorite_` 读写
- [ ] **Step 6: 提交(如有微调)**
```bash
git add mini-program/src/
git commit -m "test: H5 浏览器验证收藏功能通过"
```
---
### Task 6: 通知用户验证
- [ ] **Step 1: 提示用户操作**
告知用户:
> 收藏功能已完成真实 API 化改造:
>
> **剧本收藏**
> - 前端从 localStorage 切换到现有后端 API`POST /epicScript/favorite/toggle`
> - 收藏状态从服务器读取,不再依赖本地缓存
>
> **人生事件收藏**
> - 后端新建独立的 `t_event_favorite` 表 + API
> - 前端从 localStorage 切换到真实 API`POST /lifeEvent/favorite/toggle`
> - 删除了假接口 `/lifeEvent/favorite-placeholder`
>
> **验证方式**
> 1. 本地 H5`http://localhost:5180`)已验证通过
> 2. 后端已部署到服务器
> 3. 请在**微信开发者工具**中:
> - 打开 `unpackage/dist/dev/mp-weixin` 目录
> - 点击「编译」重新编译
> - 测试剧本收藏和人生事件收藏功能
> - 确认收藏状态刷新后仍保持
>
> 如有问题,截图反馈。
@@ -0,0 +1,874 @@
# ScriptView 统一对话页改造实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 ScriptView 心愿实现页的澄清/大纲/小说生成从独立视图切换改为统一对话流,用户提交心愿后始终停留在同一对话页面
**Architecture:** viewState 简化为 home+chat;扩展 resultMessages 消息模型支持 kind: text/card/outline/novel + 交互态字段;handleShortNovelEvent 把 SSE 事件转为对话消息追加;卡片/大纲消息内嵌交互按钮,用户操作后转 user 消息并调 followupStreamnovel_done 后底部输入栏按 generationPhase 切换到旧接口 streamScriptChat 做自由对话修改
**Tech Stack:** UniApp + Vue 3 Composition API`<script setup>`),shortNovel.js(新接口 SSE),scriptChat.js(旧接口流式对话),H5 热加载验证
---
## 文件结构
### 修改文件
- `mini-program/src/pages/main/ScriptView.vue` — 核心改造文件(约 3300 行)
- 扩展 `addResultMessage` 消息模型
- 新增 `generationPhase` 状态
- 改造 `handleShortNovelEvent` 为对话流追加消息
- 改造 `runGeneration`/`submitClarification`/`confirmOutline`/`modifyOutline`/`resumeSession` 操作对话消息
- 改造 `sendChat` 按 generationPhase 切换接口
- 新增统一 chat 视图模板,删除 clarifying/outlining/novel-generating 独立视图
### 不变文件
- `mini-program/src/components/ClarificationCard.vue` — 复用,由父组件控制 submitted 态
- `mini-program/src/services/shortNovel.js` — 新接口不变
- `mini-program/src/services/scriptChat.js` — 旧接口不变
### 验证方式
本项目无前端单元测试框架,采用 **H5 热加载 + Playwright 浏览器验证**。每个任务结束后在浏览器确认页面行为。
---
## Task 1: 扩展消息模型 + 新增 generationPhase 状态
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue`addResultMessage 函数约 1283 行,新增 generationPhase ref 约 350 行附近)
- [ ] **Step 1: 扩展 addResultMessage 支持 card/outline/交互态字段**
找到 `addResultMessage` 函数(约 1283 行),当前实现:
```javascript
const addResultMessage = ({ role, content, pending = false, kind = '' }) => {
const message = {
id: createMessageId(),
role,
kind,
content,
pending,
time: formatMessageTime()
}
resultMessages.value.push(message)
if (!pending) persistResultMessages()
keepResultAtBottom()
return message
}
```
替换为(新增 card/outline/submitted/confirmed/failed/answer/feedback 字段):
```javascript
const addResultMessage = ({ role, content, pending = false, kind = 'text', card = null, outline = null, answer = '', feedback = '', failed = false }) => {
const message = {
id: createMessageId(),
role,
kind,
content,
pending,
time: formatMessageTime(),
card,
outline,
answer,
feedback,
failed,
submitted: false,
confirmed: false
}
resultMessages.value.push(message)
if (!pending) persistResultMessages()
keepResultAtBottom()
return message
}
```
- [ ] **Step 2: 新增 generationPhase ref**
找到 `const resumeableSessionId = ref('')`(约 350 行),在其后新增一行:
```javascript
const resumeableSessionId = ref('')
const generationPhase = ref('idle')
```
`generationPhase` 取值:`'idle'`(未开始)/`'generating'`(生成流程中,用新接口)/`'done'`novel_done 后,用旧接口)。
- [ ] **Step 3: 验证热加载无编译错误**
H5 服务在后台运行(端口 5180),保存文件后 Vite 自动热加载。打开浏览器 http://localhost:5180 确认页面无白屏、Console 无语法错误。
- [ ] **Step 4: 提交**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "refactor(mini-program): 扩展 addResultMessage 消息模型 + 新增 generationPhase"
```
---
## Task 2: 改造 handleShortNovelEvent 为对话流追加消息
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue`handleShortNovelEvent 函数约 1682 行)
- [ ] **Step 1: 改造 handleShortNovelEvent**
找到 `handleShortNovelEvent` 函数(约 1682 行),当前实现是切换 viewState。替换为往 resultMessages 追加消息:
```javascript
const handleShortNovelEvent = (event) => {
const { type, session_id, payload = {} } = event
if (session_id) novelSessionId.value = session_id
switch (type) {
case 'status':
// 仅更新加载提示,不追加独立消息
generationStatus.value = payload.stage || 'processing'
break
case 'clarification_card':
addResultMessage({ role: 'assistant', kind: 'card', card: payload.card || null })
generationStatus.value = 'idle'
break
case 'outline_created':
addResultMessage({ role: 'assistant', kind: 'outline', outline: payload.outline || null })
generationStatus.value = 'idle'
break
case 'novel_start':
addResultMessage({ role: 'assistant', kind: 'novel', content: '', pending: true })
generationStatus.value = 'streaming'
break
case 'novel_delta': {
// 往最后一条 novel 消息追加 delta
const lastNovel = [...resultMessages.value].reverse().find(m => m.kind === 'novel' && m.pending)
if (lastNovel) {
lastNovel.content += payload.delta || ''
}
keepResultAtBottom()
break
}
case 'novel_done': {
// 标记最后一条 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'
persistResultMessages()
break
}
case 'error':
if (payload.code === 'DAILY_GENERATION_IN_PROGRESS' && session_id) {
resumeableSessionId.value = session_id
markGenerationFailed(payload.message || '今天已有未完成的创作,可点击继续创作恢复')
} else {
markGenerationFailed(payload.message || '生成失败')
addResultMessage({ role: 'assistant', kind: 'text', content: payload.message || '生成失败', failed: true })
}
break
}
}
```
关键变化:
- 不再切换 `viewState`(始终保持 `chat`
- `clarification_card` → 追加 kind:'card' 消息
- `outline_created` → 追加 kind:'outline' 消息
- `novel_start` → 追加 pending 的 kind:'novel' 消息
- `novel_delta` → 往最后一条 pending novel 消息追加文本
- `novel_done` → 标记 novel 消息完成,设置 `generationPhase='done'`
- [ ] **Step 2: 验证热加载无错误**
保存后浏览器确认无 Console 错误(此时 UI 还未改造,但逻辑应无语法错误)。
- [ ] **Step 3: 提交**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "refactor(mini-program): handleShortNovelEvent 改为对话流追加消息"
```
---
## Task 3: 改造 runGeneration 和交互函数
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue`runGeneration 约 1626 行,submitClarification/confirmOutline/modifyOutline/resumeSession 约 1727 行)
- [ ] **Step 1: 改造 runGeneration 进入 chat 视图并追加用户消息**
找到 `runGeneration` 函数(约 1626 行)。当前在函数中段设置 `viewState.value = 'generating'``generationDisplayText.value = display`
`viewState.value = 'generating'` 改为 `viewState.value = 'chat'`,并在 `generationDisplayText.value = display` 后新增追加用户消息的逻辑:
找到这一段:
```javascript
streamWriter.reset()
startGenerationFeedback()
ttsPlayer.reset()
viewState.value = 'generating'
keepGenerationAtBottom()
```
替换为:
```javascript
streamWriter.reset()
startGenerationFeedback()
ttsPlayer.reset()
viewState.value = 'chat'
generationPhase.value = 'generating'
resultMessages.value = []
addResultMessage({ role: 'user', kind: 'text', content: display })
keepResultAtBottom()
```
说明:进入 chat 视图,清空旧消息,把用户心愿作为第一条 user 消息追加。
- [ ] **Step 2: 改造 submitClarification 接收消息对象**
找到 `submitClarification` 函数(约 1727 行),当前签名 `const submitClarification = (answer) =>`。替换为接收消息对象 + answer:
```javascript
const submitClarification = (msg, answer) => {
if (answeringClarification.value) return
if (msg) {
msg.submitted = true
msg.answer = answer
}
addResultMessage({ role: 'user', kind: 'text', content: answer })
answeringClarification.value = true
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'answer_clarification',
payload: { answer },
onEvent: handleShortNovelEvent,
onError: (msg) => markGenerationFailed(msg)
})
setTimeout(() => { answeringClarification.value = false }, 200)
}
```
- [ ] **Step 3: 改造 confirmOutline 接收消息对象**
找到 `confirmOutline` 函数(约 1740 行),替换为:
```javascript
const confirmOutline = (msg) => {
if (msg) msg.confirmed = true
addResultMessage({ role: 'user', kind: 'text', content: '确认大纲' })
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'confirm_outline',
payload: null,
onEvent: handleShortNovelEvent,
onError: (msg) => markGenerationFailed(msg)
})
}
```
- [ ] **Step 4: 改造 modifyOutline 接收消息对象**
找到 `modifyOutline` 函数(约 1750 行),替换为:
```javascript
const modifyOutline = (msg) => {
const feedback = msg?.feedback || ''
if (!feedback.trim()) {
uni.showToast({ title: '请先填写修改意见', icon: 'none' })
return
}
if (msg) msg.confirmed = true
addResultMessage({ role: 'user', kind: 'text', content: feedback })
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'modify_outline',
payload: { feedback },
onEvent: handleShortNovelEvent,
onError: (msg) => markGenerationFailed(msg)
})
}
```
- [ ] **Step 5: 改造 resumeSession 在对话流内恢复**
找到 `resumeSession` 函数(约 1761 行),替换为:
```javascript
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()
try {
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'retry',
payload: null,
onEvent: handleShortNovelEvent,
onError: (msg) => {
markGenerationFailed(msg)
analytics.track('script_generate_fail', {
source: 'resume',
error: msg
}, { eventType: 'script', pagePath })
}
})
} catch (error) {
markGenerationFailed(error.message || '恢复失败')
} finally {
generating.value = false
}
}
```
关键变化:不再切换 viewState(保持 chat),改为追加 user 消息"继续之前的创作"。
- [ ] **Step 6: 验证热加载无错误**
保存后浏览器确认无 Console 错误。
- [ ] **Step 7: 提交**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "refactor(mini-program): runGeneration+交互函数改为操作对话消息"
```
---
## Task 4: 新增统一 chat 视图模板 + 删除旧独立视图
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue`template 部分,约 89-183 行的 clarifying/outlining 块 + 139-183 的 generating 块 + 185-302 的 result 块)
- [ ] **Step 1: 删除 clarifying 和 outlining 独立视图块**
找到 `clarifying` 视图块(约 89 行 `<view v-else-if="viewState === 'clarifying'"` 到 102 行 `</view>`)和 `outlining` 视图块(约 104 行 `<view v-else-if="viewState === 'outlining'"` 到 137 行 `</view>`),**整块删除**。
删除这两个块后,template 里不再有 viewState==='clarifying' 和 'outlining' 的分支。
- [ ] **Step 2: 改造 generating 视图为 chat 视图**
找到 generating 视图块(约 139 行 `<view v-else-if="viewState === 'generating"`,但由于 Step 1 删除了前面两个块,行号会前移)。把整个 generating 块替换为统一 chat 视图:
`<view v-else-if="viewState === 'generating'" class="generation-view">` 整块(到对应的 `</view>` 结束)替换为:
```vue
<view v-else-if="viewState === 'chat'" class="chat-page">
<view class="chat-top-actions">
<view class="history-button" @click="openScriptLibrary">
<view class="history-lines">
<view></view>
<view></view>
<view></view>
</view>
<text>历史</text>
</view>
<button class="page-close-btn" @click="closeResult">×</button>
</view>
<scroll-view
class="chat-scroll"
scroll-y
:scroll-top="resultCommandScrollTop"
:scroll-into-view="resultScrollTarget"
:scroll-with-animation="true"
:enhanced="true"
:show-scrollbar="false"
@scroll="handleResultScroll"
>
<view class="chat-scroll-content">
<view id="result-scroll-top" class="result-scroll-top"></view>
<view class="conversation compact">
<view
v-for="msg in resultMessages"
:key="msg.id"
>
<!-- user text -->
<view v-if="msg.role === 'user' && msg.kind === 'text'" class="chat-bubble user">
<text>{{ msg.content }}</text>
<text class="bubble-time">{{ msg.time }}</text>
</view>
<!-- assistant card -->
<view v-else-if="msg.kind === 'card'" class="chat-bubble system">
<ClarificationCard
v-if="!msg.submitted && msg.card"
:card="msg.card"
@submit="(answer) => submitClarification(msg, answer)"
/>
<view v-else class="card-answered">
<text>已回答{{ msg.answer }}</text>
</view>
</view>
<!-- assistant outline -->
<view v-else-if="msg.kind === 'outline'" class="chat-bubble system">
<view v-if="msg.outline">
<view v-if="msg.outline.title" class="outline-title">
<text>{{ msg.outline.title }}</text>
</view>
<view v-if="msg.outline.logline" class="outline-logline">
<text>{{ msg.outline.logline }}</text>
</view>
<view v-if="Array.isArray(msg.outline.beats)" class="outline-beats">
<view v-for="(beat, idx) in msg.outline.beats" :key="idx" class="beat-item">
<text class="beat-order">{{ beat.order || idx + 1 }}</text>
<view class="beat-content">
<text class="beat-title">{{ beat.title || '' }}</text>
<text v-if="beat.summary" class="beat-summary">{{ beat.summary }}</text>
</view>
</view>
</view>
<view v-if="msg.outline.ending" class="outline-ending">
<text class="ending-label">结局</text>
<text class="ending-text">{{ msg.outline.ending }}</text>
</view>
</view>
<view v-if="!msg.confirmed" class="outline-actions">
<input v-model="msg.feedback" placeholder="如需修改请输入意见" class="outline-feedback" />
<view class="outline-buttons">
<view class="btn-secondary" @click="modifyOutline(msg)">修改大纲</view>
<view class="btn-primary" @click="confirmOutline(msg)">确认大纲</view>
</view>
</view>
</view>
<!-- assistant novel -->
<view v-else-if="msg.kind === 'novel'" class="chat-bubble system">
<text class="novel-text">{{ msg.content }}<text v-if="msg.pending" class="typing-cursor">|</text></text>
</view>
<!-- assistant text (status/error) -->
<view v-else class="chat-bubble system">
<text :class="{ 'generation-error': msg.failed }">{{ msg.content }}</text>
<text class="bubble-time">{{ msg.time }}</text>
</view>
</view>
<!-- 底部 loading -->
<view v-if="generating && generationStatus !== 'failed'" class="chat-loading">
<view class="loading-orbit" :class="{ streaming: generationStatus === 'streaming' }">
<view class="orbit-ring outer"></view>
<view class="orbit-ring inner"></view>
<view class="orbit-core"></view>
</view>
<text class="loading-copy">{{ generationCopy }}</text>
</view>
<!-- 失败继续创作入口 -->
<view v-if="generationStatus === 'failed' && resumeableSessionId" class="resume-action">
<button class="generation-action primary" @click="resumeSession">继续创作</button>
</view>
<view :id="resultScrollAnchor" class="result-scroll-anchor"></view>
</view>
</view>
</scroll-view>
<!-- 底部输入栏novel_done 后启用 -->
<view v-if="generationPhase === 'done'" class="chat-input-bar">
<view class="result-input-shell">
<textarea
class="result-chat-input"
v-model="chatInput"
:auto-height="true"
:show-confirm-bar="false"
maxlength="500"
confirm-type="send"
placeholder="输入修改建议,或让 AI 继续生成"
@confirm="sendChat"
/>
</view>
<view class="chat-send-btn" :class="{ disabled: !chatInput.trim() || generating }" @click="sendChat">发送</view>
</view>
</view>
```
- [ ] **Step 3: 删除独立的 result 视图块**
找到原 result 视图块(`<view v-else class="result-page">`,约 185 行到 302 行)。由于 chat 视图已包含所有对话功能,**整块删除**原 result-page 块。
删除后 template 的 v-if/v-else-if 链为:`home``chat`
- [ ] **Step 4: 新增 chat 视图相关样式**
`<style scoped>` 部分末尾添加 chat 视图样式(复用现有 result/generation 样式,新增缺失部分):
```scss
.chat-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #13091f;
}
.chat-top-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 32rpx;
}
.chat-scroll {
flex: 1;
height: calc(100vh - 180rpx);
}
.chat-scroll-content {
padding: 0 24rpx 40rpx;
}
.card-answered {
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
padding: 16rpx 0;
}
.outline-title {
color: #ffffff;
font-size: 40rpx;
font-weight: 700;
margin-bottom: 16rpx;
}
.outline-logline {
color: rgba(255, 255, 255, 0.85);
font-size: 28rpx;
line-height: 1.6;
margin-bottom: 24rpx;
}
.outline-beats {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-bottom: 24rpx;
}
.beat-item {
display: flex;
gap: 20rpx;
align-items: flex-start;
}
.beat-order {
flex-shrink: 0;
width: 56rpx;
height: 56rpx;
background: #087e8b;
border-radius: 50%;
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.beat-content {
flex: 1;
display: flex;
flex-direction: column;
}
.beat-title {
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
}
.beat-summary {
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
margin-top: 8rpx;
line-height: 1.5;
}
.outline-ending {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 2rpx solid rgba(255, 255, 255, 0.1);
}
.ending-label {
color: rgba(255, 255, 255, 0.6);
font-size: 24rpx;
display: block;
margin-bottom: 8rpx;
}
.ending-text {
color: #ffffff;
font-size: 28rpx;
line-height: 1.6;
}
.outline-actions {
margin-top: 24rpx;
}
.outline-feedback {
width: 100%;
padding: 20rpx;
background: rgba(255, 255, 255, 0.05);
border-radius: 12rpx;
color: #ffffff;
font-size: 28rpx;
margin-bottom: 16rpx;
box-sizing: border-box;
}
.outline-buttons {
display: flex;
gap: 16rpx;
justify-content: flex-end;
}
.novel-text {
color: #ffffff;
font-size: 28rpx;
line-height: 1.8;
white-space: pre-wrap;
}
.typing-cursor {
color: #087e8b;
animation: blink 900ms steps(1) infinite;
}
.chat-loading {
display: flex;
align-items: center;
gap: 16rpx;
padding: 24rpx 0;
}
.resume-action {
padding: 24rpx 0;
display: flex;
justify-content: center;
}
.chat-input-bar {
display: flex;
align-items: flex-end;
gap: 16rpx;
padding: 16rpx 24rpx;
background: rgba(255, 255, 255, 0.04);
border-top: 2rpx solid rgba(255, 255, 255, 0.1);
}
.result-input-shell {
flex: 1;
background: rgba(255, 255, 255, 0.06);
border-radius: 12rpx;
padding: 8rpx 16rpx;
}
.result-chat-input {
width: 100%;
min-height: 60rpx;
padding: 12rpx 0;
color: #ffffff;
font-size: 28rpx;
}
.chat-send-btn {
padding: 16rpx 32rpx;
background: #087e8b;
border-radius: 32rpx;
color: #ffffff;
font-size: 28rpx;
}
.chat-send-btn.disabled {
opacity: 0.5;
}
@keyframes blink {
50% { opacity: 0; }
}
```
- [ ] **Step 5: 验证热加载渲染**
保存后浏览器打开 http://localhost:5180,进入心愿页输入心愿发送,确认:
- 页面进入 chat 视图(对话页)
- 用户心愿作为气泡显示
- 澄清卡片作为 AI 消息出现在对话流
- Console 无错误
- [ ] **Step 6: 提交**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "feat(mini-program): 统一 chat 视图,删除 clarifying/outlining/result 独立视图"
```
---
## Task 5: 改造 sendChat 按 generationPhase 切换接口
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue`sendChat 函数约 701 行)
- [ ] **Step 1: 改造 sendChat 根据 generationPhase 切换接口**
找到 `sendChat` 函数(约 701 行)。当前实现用旧接口 `streamScriptChat` + `createMessage`
在函数开头插入 generationPhase 判断:如果是 `'generating'`(生成流程中,理论上 novel_done 前底部输入栏不显示,但防御性处理),直接 return;如果是 `'done'`,走原有旧接口逻辑。
`sendChat` 函数替换为:
```javascript
const sendChat = async () => {
if (!chatInput.value.trim() || generating.value) return
const content = chatInput.value.trim()
chatInput.value = ''
// novel_done 前底部输入栏不显示,此处只处理 done 态的后续对话
if (generationPhase.value !== 'done') return
const messageId = currentVersionMessageId.value
const userMessageRes = await createMessage({
conversationId: conversationId.value,
userId: getCurrentUserId(),
content,
senderType: 'user',
contentType: 'chat'
})
const userMessageId = userMessageRes.data?.id
resultMessages.value.push({
id: userMessageId || createMessageId(),
role: 'user',
kind: 'text',
content,
pending: false,
time: formatMessageTime(),
submitted: false,
confirmed: false
})
keepResultAtBottom()
generating.value = true
generationStatus.value = 'streaming'
await streamScriptChat({
operationType: 'chat',
conversationId: conversationId.value,
messageId,
userMessageId,
content,
scriptId: scriptId.value,
onDelta: (delta) => {
// 往最后一条 AI 消息追加(如有)
const lastAssistant = [...resultMessages.value].reverse().find(m => m.role === 'assistant' && m.kind === 'novel')
if (lastAssistant) {
lastAssistant.content += delta
}
},
onDone: () => {
generating.value = false
generationStatus.value = 'idle'
// 重新加载持久化消息以获取最新版本
loadMessages()
},
onError: (error) => {
generating.value = false
generationStatus.value = 'idle'
uni.showToast({ title: error || '生成失败', icon: 'none' })
}
})
}
```
关键变化:
- 只在 `generationPhase === 'done'` 时处理(底部输入栏此时才显示)
- 用户消息追加到 `resultMessages`(统一对话流)
- `onDelta` 往最后一条 AI 消息追加文本
- [ ] **Step 2: 验证热加载无错误**
保存后浏览器确认无 Console 错误。
- [ ] **Step 3: 提交**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "feat(mini-program): sendChat 按 generationPhase 切换接口,消息进统一对话流"
```
---
## Task 6: 端到端浏览器验证
**Files:**
- 无代码改动,纯浏览器验证
- [ ] **Step 1: 确认 H5 服务运行**
Run: `python dev-services.py status`
Expected: mini-program 端口 5180 运行中
若未运行,启动:`cd mini-program && npm run dev:h5 -- --host 127.0.0.1 --port 5180`(后台运行)
- [ ] **Step 2: Playwright 验证完整对话流程**
使用 Playwright MCP 打开 http://localhost:5180
1. 登录(手机号 13800138000 + 验证码 123456
2. 进入心愿页,输入"今天我想当 CEO",发送
3. **验证**:进入 chat 对话页,用户心愿作为气泡显示在对话流
4. **验证**:澄清卡片作为 AI 消息出现在对话流(不在独立页面)
5. 选择选项,提交
6. **验证**:卡片变为"已回答"态,user 消息追加,下一轮澄清或大纲出现
7. 若进入大纲:**验证**大纲作为 AI 消息出现(带确认/修改按钮)
8. 确认大纲
9. **验证**:小说流式生成作为一条 AI 消息逐字出现
10. novel_done 后:**验证**底部输入栏出现,可输入修改建议
- [ ] **Step 3: 验证继续创作(DAILY_GENERATION_IN_PROGRESS**
若触发每日限制:
1. **验证**:对话流内显示失败提示 + "继续创作"按钮
2. 点击"继续创作"
3. **验证**:恢复会话,新澄清/大纲作为消息追加到对话流
- [ ] **Step 4: 检查 Console 和 Network**
- Console:无业务错误(`uni.getRecorderManager not supported` 平台限制可忽略)
- Network`/shortNovel/stream``/shortNovel/followup` 返回 SSE 事件流,无 4xx/5xx
- [ ] **Step 5: 验收报告**
输出验收报告:每个步骤通过/失败 + 截图证据 + 最终结论
---
## 验收标准
- [ ] 提交心愿后进入 chat 对话页,用户心愿作为气泡显示
- [ ] 澄清卡片作为 AI 消息出现在对话流(非独立页面)
- [ ] 卡片提交后变"已回答"态,user 消息追加
- [ ] 大纲作为 AI 消息出现,确认/修改后按钮消失
- [ ] 小说流式生成作为一条 AI 消息逐字出现
- [ ] novel_done 后底部输入栏出现,继续修改用旧接口工作
- [ ] 失败时对话流内显示"继续创作"入口
- [ ] 整个过程不切换视图,用户始终在同一对话页
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,639 @@
# SSE 流式传输与历史列表保存修复实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复小说生成页面的 SSE 流式传输(实现逐字输出)和历史列表保存问题
**Architecture:** 通过三个层面的修改实现真正的流式传输:后端 OkHttp 使用严格行读取 + 显式 flush、nginx 禁用代理缓冲、前端添加诊断日志确认 originalQuery 传递
**Tech Stack:** Spring Boot 2.7.18, OkHttp 4.12.0, nginx, Vue 3 (UniApp)
---
## 文件结构映射
**修改的文件:**
1. `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:140-192` - 后端 SSE 转发逻辑
2. `/etc/nginx/sites-enabled/lifescript.happylifeos.com.conf` - nginx 配置(服务器)
3. `mini-program/src/pages/main/ScriptView.vue:1650-1843` - 前端事件处理
**不创建新文件** - 所有修改都是在现有文件中添加日志和优化逻辑
---
## Task 1: 后端 OkHttp 读取优化
**Files:**
- Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:140-192`
- [ ] **Step 1: 在 forwardSse 方法开头添加开始日志**
打开 `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java`,在第 140 行(`BufferedSource source = responseBody.source();` 之后)添加:
```java
log.info("[ShortNovel SSE] 开始读取上游响应: path={}, userId={}", path, currentUserId);
```
- [ ] **Step 2: 将 readUtf8Line() 改为 readUtf8LineStrict()**
在第 145 行,将:
```java
String line = source.readUtf8Line();
```
改为:
```java
String line = source.readUtf8LineStrict();
```
- [ ] **Step 3: 在读取行后添加调试日志**
在第 146 行(`if (line == null) break;` 之后)添加:
```java
log.debug("[ShortNovel SSE] 读取到行: length={}, timestamp={}",
line.length(), System.currentTimeMillis());
```
- [ ] **Step 4: 在事件解析后添加时间戳日志**
在第 160 行(`String type = event.getString("type");` 之后)添加:
```java
long eventTimestamp = System.currentTimeMillis();
log.info("[ShortNovel SSE] 处理事件: type={}, timestamp={}", type, eventTimestamp);
```
- [ ] **Step 5: 在 novel_done 事件处理中添加详细日志**
在第 165 行(`if ("novel_done".equals(type)) {` 之后)添加:
```java
JSONObject payload = event.getJSONObject("payload");
log.info("[ShortNovel SSE] novel_done 事件: originalQuery={}, payload={}",
originalQuery, payload != null);
```
- [ ] **Step 6: 在保存前添加 originalQuery 空字符串检查**
将第 165 行的条件:
```java
if (payload != null && originalQuery != null) {
```
改为:
```java
if (payload != null && originalQuery != null && !originalQuery.trim().isEmpty()) {
```
- [ ] **Step 7: 在保存小说前添加日志**
在第 172 行(`Map<String, String> saveResult = epicScriptDialogueServiceImpl.saveNovelResult(` 之前)添加:
```java
log.info("[ShortNovel SSE] 开始保存小说: userId={}, queryLength={}, textLength={}",
currentUserId, originalQuery.length(), fullText.length());
```
- [ ] **Step 8: 在保存成功后添加日志**
在第 176 行(`Map<String, String> saveResult = ...` 之后)添加:
```java
log.info("[ShortNovel SSE] 小说保存成功: scriptId={}", saveResult.get("scriptId"));
```
- [ ] **Step 9: 在保存失败时添加警告日志**
在第 183 行(`} else {` 之后)添加:
```java
log.warn("[ShortNovel SSE] novel_done 事件缺少 full_text");
```
- [ ] **Step 10: 在跳过保存时添加警告日志**
在第 185 行(`} else {` 之后)添加:
```java
log.warn("[ShortNovel SSE] novel_done 事件跳过保存: originalQuery={}, payload={}",
originalQuery, payload);
```
- [ ] **Step 11: 在 emitter.send() 后添加 flush 触发**
在第 187 行(`emitter.send(SseEmitter.event().name(type).data(event.toJSONString()));` 之后)添加:
```java
emitter.send(SseEmitter.event().comment("")); // 触发 flush
```
- [ ] **Step 12: 在循环结束后添加完成日志**
在第 192 行(`}` 之前)添加:
```java
log.info("[ShortNovel SSE] 完成读取上游响应");
```
- [ ] **Step 13: 编译后端验证**
```bash
cd server
mvn clean install -DskipTests
```
预期输出:`BUILD SUCCESS`
- [ ] **Step 14: 提交后端修改**
```bash
git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java
git commit -m "feat: 优化 SSE 转发逻辑,使用严格行读取和显式 flush"
```
---
## Task 2: nginx 配置优化
**Files:**
- Modify: `/etc/nginx/sites-enabled/lifescript.happylifeos.com.conf`(服务器)
- [ ] **Step 1: SSH 登录服务器**
```bash
ssh root@101.200.208.45
```
- [ ] **Step 2: 备份当前 nginx 配置**
```bash
sudo cp /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf.backup.$(date +%Y%m%d_%H%M%S)
```
- [ ] **Step 3: 编辑 nginx 配置文件**
```bash
sudo nano /etc/nginx/sites-enabled/lifescript.happylifeos.com.conf
```
找到 `location /api/shortNovel/ {` 块(应该在第 50-65 行左右),将其修改为:
```nginx
location /api/shortNovel/ {
proxy_pass http://127.0.0.1:19089;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 禁用代理缓冲,确保 SSE 事件立即转发
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_set_header Connection '';
# 超时配置
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
```
**关键新增行:**
- `proxy_buffering off;` - 禁用响应缓冲
- `proxy_cache off;` - 禁用缓存
- `chunked_transfer_encoding on;` - 启用分块传输
- `proxy_set_header Connection '';` - 清除 Connection 头
- [ ] **Step 4: 验证 nginx 配置语法**
```bash
sudo nginx -t
```
预期输出:
```
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
```
- [ ] **Step 5: 重新加载 nginx 配置**
```bash
sudo systemctl reload nginx
```
- [ ] **Step 6: 验证 nginx 服务状态**
```bash
sudo systemctl status nginx
```
预期输出:`active (running)`
- [ ] **Step 7: 提交 nginx 配置变更(到 git)**
```bash
# 在本地创建 nginx 配置文档记录变更
cat > docs/nginx-config-changes/2026-07-21-shortnovel-sse-optimization.md << 'EOF'
---
author: AI Assistant
created_at: 2026-07-21
purpose: 记录 nginx 配置优化,禁用 SSE 响应缓冲
---
# nginx 配置优化 - ShortNovel SSE
**文件:** `/etc/nginx/sites-enabled/lifescript.happylifeos.com.conf`
**变更内容:**
在 `location /api/shortNovel/` 块中添加了以下配置:
- `proxy_buffering off;` - 禁用代理缓冲
- `proxy_cache off;` - 禁用缓存
- `chunked_transfer_encoding on;` - 启用分块传输编码
- `proxy_set_header Connection '';` - 清除 Connection 头
**原因:**
- nginx 默认启用 `proxy_buffering on`,会缓冲后端响应
- 对于 SSEServer-Sent Events)长连接,缓冲会导致事件无法立即到达前端
- 禁用缓冲后,每个事件都能立即转发给客户端,实现真正的流式传输
**验证:**
- 使用 `curl -N https://lifescript.happylifeos.com/api/shortNovel/stream` 测试
- 应该能够逐条接收 SSE 事件,而不是等待所有事件完成后一次性接收
EOF
git add docs/nginx-config-changes/2026-07-21-shortnovel-sse-optimization.md
git commit -m "docs: 记录 nginx SSE 配置优化"
```
---
## Task 3: 前端事件处理优化
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue:1650-1843`
- [ ] **Step 1: 在 handleShortNovelEvent 函数开头添加日志**
打开 `mini-program/src/pages/main/ScriptView.vue`,在第 1691 行(`const handleShortNovelEvent = (event) => {` 之后)修改为:
```javascript
const handleShortNovelEvent = (event) => {
const { type, session_id, payload = {} } = event
const timestamp = Date.now()
console.log('[ScriptView] 收到事件:', { type, session_id, timestamp, payload })
if (session_id) novelSessionId.value = session_id
```
- [ ] **Step 2: 在 novel_delta case 中添加日志**
在第 1715 行(`case 'novel_delta': {` 之后)修改为:
```javascript
case 'novel_delta': {
const lastNovel = [...resultMessages.value].reverse().find(m => m.kind === 'novel' && m.pending)
if (lastNovel) {
const delta = payload.delta || ''
console.log('[ScriptView] novel_delta:', {
deltaLength: delta.length,
currentLength: lastNovel.content.length,
timestamp
})
lastNovel.content += delta
}
keepResultAtBottom()
break
}
```
- [ ] **Step 3: 在 novel_done case 中添加日志**
在第 1724 行(`case 'novel_done': {` 之后)修改为:
```javascript
case 'novel_done': {
console.log('[ScriptView] novel_done:', {
scriptId: payload.scriptId,
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
}
```
- [ ] **Step 4: 在 startNovelGeneration 函数中添加日志确认 firstQuery 设置**
在第 1650 行(`firstQuery.value = text` 之后)添加:
```javascript
firstQuery.value = text // 记住首次心愿,用于后续 followup 保存剧本
console.log('[ScriptView] 设置 firstQuery:', { text, length: text.length })
```
- [ ] **Step 5: 在 submitClarification 函数中添加日志**
在第 1768 行(`currentStreamTask.value = followupStream({` 之前)添加:
```javascript
console.log('[ScriptView] submitClarification:', {
sessionId: novelSessionId.value,
originalQuery: firstQuery.value,
originalQueryLength: firstQuery.value?.length
})
```
- [ ] **Step 6: 在 confirmOutline 函数中添加日志**
在第 1786 行(`currentStreamTask.value = followupStream({` 之前)添加:
```javascript
console.log('[ScriptView] confirmOutline:', {
sessionId: novelSessionId.value,
originalQuery: firstQuery.value,
originalQueryLength: firstQuery.value?.length
})
```
- [ ] **Step 7: 在 modifyOutline 函数中添加日志**
在第 1802 行(`currentStreamTask.value = followupStream({` 之前)添加:
```javascript
console.log('[ScriptView] modifyOutline:', {
sessionId: novelSessionId.value,
originalQuery: firstQuery.value,
originalQueryLength: firstQuery.value?.length
})
```
- [ ] **Step 8: 在 resumeSession 函数中添加日志**
在第 1826 行(`currentStreamTask.value = followupStream({` 之前)添加:
```javascript
console.log('[ScriptView] resumeSession:', {
sessionId: novelSessionId.value,
originalQuery: firstQuery.value,
originalQueryLength: firstQuery.value?.length
})
```
- [ ] **Step 9: 构建小程序验证**
```bash
cd mini-program
npm run build:mp-weixin
```
预期输出:`Build complete.`
- [ ] **Step 10: 提交前端修改**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "feat: 前端添加 SSE 事件诊断日志,确认 originalQuery 传递"
```
---
## Task 4: 部署与验证
**Files:**
- 无文件修改,纯部署和验证任务
- [ ] **Step 1: 上传后端 JAR 到服务器**
```bash
scp server/target/server-1.0.0.jar root@101.200.208.45:/data/programs/emotion-museum/emotion-single-1.0.0.jar
```
预期输出:`100%` 传输完成
- [ ] **Step 2: 重启后端服务**
```bash
ssh root@101.200.208.45 "cd /data/programs/emotion-museum && ./deploy-server.sh test"
```
预期输出:`服务启动成功!`
- [ ] **Step 3: 验证后端日志 - 检查事件逐条到达**
```bash
ssh root@101.200.208.45 "tail -f /data/logs/emotion-museum/emotion-single.log | grep 'ShortNovel SSE'"
```
然后在浏览器中触发一次小说生成,观察日志应该显示:
- `[ShortNovel SSE] 开始读取上游响应`
- 多个 `[ShortNovel SSE] 处理事件: type=novel_delta, timestamp=...`(时间戳应该逐条递增,间隔 > 100ms)
- `[ShortNovel SSE] novel_done 事件: originalQuery=..., payload=...`
- `[ShortNovel SSE] 开始保存小说: ...`
- `[ShortNovel SSE] 小说保存成功: scriptId=...`
- `[ShortNovel SSE] 完成读取上游响应`
- [ ] **Step 4: 验证前端日志 - 检查事件逐字显示**
在微信开发者工具或浏览器中打开小程序,进入小说生成页面:
1. 输入心愿文本并生成
2. 打开 Console 面板
3. 观察日志应该显示:
- `[ScriptView] 设置 firstQuery: {text: "...", length: ...}`
- `[ScriptView] 收到事件: {type: "novel_start", ...}`
- 多个 `[ScriptView] novel_delta: {deltaLength: 1-5, currentLength: ..., timestamp: ...}`(应该逐条出现)
- `[ScriptView] novel_done: {scriptId: "...", hasFullText: true, ...}`
4. **关键验证点:** 小说内容应该逐字显示,而不是一次性出现
- [ ] **Step 5: 验证历史列表保存**
1. 完成一次完整的小说生成流程
2. 返回主页,进入"历史"页面
3. 验证新生成的小说是否出现在列表中
4. 点击该小说,确认内容完整
- [ ] **Step 6: 验证数据库记录**
```bash
ssh root@101.200.208.45 "mysql -u root -pEmotionMuseum2025*# emotion_museum -e \"SELECT id, user_id, prompt, LEFT(content, 50) as content_preview, create_time FROM t_epic_script ORDER BY create_time DESC LIMIT 5;\""
```
预期输出:应该看到最新的记录,`create_time` 应该是刚才生成的时间
- [ ] **Step 7: 如果流式输出仍然不工作,检查上游服务**
如果日志显示所有 `novel_delta` 事件的时间戳都在同一毫秒,说明上游服务没有真正流式发送:
```bash
ssh root@101.200.208.45 "tail -100 /data/logs/emotion-museum/emotion-single.log | grep 'novel_delta' | awk '{print \$1, \$2}' | uniq -c"
```
如果输出显示所有事件都在同一秒,需要联系上游服务提供者确认是否支持真正的流式传输。
- [ ] **Step 8: 提交部署验证文档**
```bash
cat > docs/deployment-verification/2026-07-21-sse-streaming-fix.md << 'EOF'
---
author: AI Assistant
created_at: 2026-07-21
purpose: 记录 SSE 流式传输修复的部署验证过程
---
# SSE 流式传输修复 - 部署验证
**部署时间:** 2026-07-21
**修改内容:**
1. 后端:使用 `readUtf8LineStrict()` + 显式 flush
2. nginx:添加 `proxy_buffering off` 等配置
3. 前端:添加诊断日志
**验证结果:**
- [ ] 后端日志显示事件逐条到达(时间间隔 > 100ms)
- [ ] 前端 Console 显示 novel_delta 事件逐条接收
- [ ] 小说内容逐字显示
- [ ] 历史列表正确保存新生成的小说
- [ ] 数据库 t_epic_script 表有新记录
**问题与解决:**
(在此记录遇到的问题和解决方案)
EOF
git add docs/deployment-verification/2026-07-21-sse-streaming-fix.md
git commit -m "docs: 记录 SSE 流式传输修复的部署验证"
```
---
## Task 5: 测试用例执行
**Files:**
- 无文件修改,纯测试任务
- [ ] **Step 1: 执行测试用例 1 - 完整生成流程**
1. 输入心愿文本:"我想写一个关于时间旅行的故事"
2. 回答澄清问题(例如选择时代背景)
3. 确认大纲
4. 等待小说生成完成
5. **验证:**
- 前端逐字显示小说内容(观察 Console 日志)
- 后端日志显示事件逐条到达(`tail -f` 查看)
- 数据库中出现新记录
- 历史列表显示新生成的小说
- [ ] **Step 2: 执行测试用例 2 - 修改大纲后重新生成**
1. 输入心愿文本
2. 回答澄清问题
3. 点击"修改大纲",填写修改意见
4. 等待小说生成完成
5. **验证:** 同测试用例 1
- [ ] **Step 3: 执行测试用例 3 - 继续之前的创作**
1. 输入心愿文本
2. 回答澄清问题
3. 中断流程(关闭页面或刷新)
4. 重新进入页面,点击"继续创作"
5. 等待小说生成完成
6. **验证:** 同测试用例 1
- [ ] **Step 4: 记录测试结果**
```bash
cat > docs/test-results/2026-07-21-sse-streaming-test.md << 'EOF'
---
author: AI Assistant
created_at: 2026-07-21
purpose: 记录 SSE 流式传输修复的测试结果
---
# SSE 流式传输修复 - 测试结果
**测试时间:** 2026-07-21
## 测试用例 1:完整生成流程
- [ ] 前端逐字显示
- [ ] 后端日志显示事件逐条到达
- [ ] 数据库记录正确
- [ ] 历史列表显示
**测试结果:** PASS / FAIL
**问题描述:**(如果有)
## 测试用例 2:修改大纲后重新生成
- [ ] 前端逐字显示
- [ ] 后端日志显示事件逐条到达
- [ ] 数据库记录正确
- [ ] 历史列表显示
**测试结果:** PASS / FAIL
## 测试用例 3:继续之前的创作
- [ ] 前端逐字显示
- [ ] 后端日志显示事件逐条到达
- [ ] 数据库记录正确
- [ ] 历史列表显示
**测试结果:** PASS / FAIL
## 总结
(在此总结测试结果和发现的问题)
EOF
git add docs/test-results/2026-07-21-sse-streaming-test.md
git commit -m "docs: 记录 SSE 流式传输修复的测试结果"
```
---
## 完成标准
1. ✅ 所有后端日志显示事件逐条到达(时间间隔 > 100ms)
2. ✅ 前端 Console 显示 novel_delta 事件逐条接收
3. ✅ 小说内容在前端逐字显示,用户可以观察到文字逐个出现
4. ✅ 每次成功的小说生成都保存到数据库
5. ✅ 历史列表正确显示新生成的小说
6. ✅ 所有测试用例通过
---
## 风险评估与缓解
### 风险 1:上游服务不支持真正的流式传输
**检测方法:** 查看后端日志,如果所有 `novel_delta` 事件的时间戳都在同一毫秒,说明上游服务一次性发送了所有数据。
**缓解措施:** 联系上游服务提供者(`http://49.232.138.53:8010`),确认是否支持真正的流式传输。
### 风险 2nginx 配置不生效
**检测方法:** 使用 `curl -N` 测试 SSE 接口,观察是否逐条接收事件。
**缓解措施:** 检查 nginx error log,确认配置语法正确;检查是否有其他 location 块覆盖了 `/api/shortNovel/` 的配置。
### 风险 3SseEmitter flush 不工作
**检测方法:** 查看后端日志,确认事件处理时间戳和发送时间戳的差异。
**缓解措施:** 如果 `emitter.send(SseEmitter.event().comment(""))` 仍然不 flush,考虑更换为 `ResponseBodyEmitter``StreamingResponseBody`
@@ -0,0 +1,490 @@
# H5 模式 Stream 超时修复实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复小程序 H5 模式下 stream 请求 60 秒超时问题,使用 fetch + ReadableStream + AbortController 实现真正的长连接流式读取
**Architecture:**`mini-program/src/services/shortNovel.js` 中实现双路径策略 - H5 环境使用浏览器原生 fetch API(支持自定义超时),小程序环境保持原有 uni.request 逻辑
**Tech Stack:** Vue 3 (UniApp), fetch API, ReadableStream, AbortController, TextDecoder
---
## 文件结构映射
**修改的文件:**
1. `mini-program/src/services/shortNovel.js` - 添加 H5 环境检测和 fetch 实现
**不创建新文件** - 所有修改都是在现有文件中添加 H5 路径实现
---
## Task 1: 添加环境检测常量和 H5 SSE 核心函数
**Files:**
- Modify: `mini-program/src/services/shortNovel.js`
- [ ] **Step 1: 在文件顶部添加环境检测常量**
打开 `mini-program/src/services/shortNovel.js`,在第 1 行(import 语句之后)添加:
```javascript
// 环境检测:H5 模式下存在 window.fetch
const isH5 = typeof window !== 'undefined' && typeof window.fetch === 'function'
// SSE 请求超时时间(毫秒)
const SSE_TIMEOUT_MS = 300000
```
- [ ] **Step 2: 添加 H5 SSE 事件解析函数**
`decodeChunk` 函数(第 112-140 行)之后、`consumeSseText` 函数之前,添加新的 `h5ConsumeSseText` 函数:
```javascript
/**
* H5 环境 SSE 事件解析
* 解析单个 SSE 事件块
* 事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
*/
function h5ConsumeSseText(text, onEvent, onError) {
const lines = text.split('\n')
let dataBuffer = ''
for (const line of lines) {
if (line.startsWith('data:')) {
dataBuffer += line.slice(5).trim()
} else if (line === '' && dataBuffer) {
// 空行表示事件结束
const dataStr = dataBuffer
dataBuffer = ''
if (dataStr === '[DONE]') return
try {
const event = JSON.parse(dataStr)
onEvent?.(event)
} catch (e) {
onError?.(`SSE 解析失败: ${e.message}`)
}
}
}
}
```
- [ ] **Step 3: 添加 H5 SSE 流式读取核心函数**
`h5ConsumeSseText` 函数之后,添加 `h5NovelStream` 函数:
```javascript
/**
* H5 环境 SSE 流式读取核心实现
* 使用浏览器原生 fetch + ReadableStream + AbortController
* @param {string} url - 请求 URL
* @param {Object} body - 请求体
* @param {Function} onEvent - 事件回调
* @param {Function} onError - 错误回调
* @returns {Object} 包含 abort 方法的对象
*/
function h5NovelStream(url, body, onEvent, onError) {
// 1. 创建 AbortController 用于超时控制
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), SSE_TIMEOUT_MS)
// 2. 发起 fetch 请求
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
...getAuthHeader()
},
body: JSON.stringify(body),
signal: controller.signal
}).then(response => {
// 3. 检查响应状态
if (!response.ok) {
clearTimeout(timeoutId)
onError?.(`请求失败: HTTP ${response.status}`)
return
}
// 4. 获取 ReadableStream 读取器
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
// 5. 循环读取流数据
function pump() {
return reader.read().then(({ done, value }) => {
if (done) {
clearTimeout(timeoutId)
// 处理缓冲区残留数据
if (buffer.trim()) h5ConsumeSseText(buffer, onEvent, onError)
return
}
// 6. 解码二进制块为文本
buffer += decoder.decode(value, { stream: true })
// 7. 解析完整的事件(按 \n\n 分隔)
const events = buffer.split('\n\n')
buffer = events.pop() // 最后一个可能不完整,保留到下次
for (const event of events) {
h5ConsumeSseText(event, onEvent, onError)
}
// 8. 继续读取
return pump()
})
}
return pump()
}).catch(err => {
clearTimeout(timeoutId)
if (err.name === 'AbortError') {
onError?.('请求超时(300秒)')
} else {
onError?.(err.message || '网络请求失败')
}
})
// 9. 返回 abort 接口
return {
abort: () => {
clearTimeout(timeoutId)
controller.abort()
}
}
}
```
---
## Task 2: 修改 startNovelStream 函数支持 H5 环境
**Files:**
- Modify: `mini-program/src/services/shortNovel.js:16-55`
- [ ] **Step 1: 在 startNovelStream 函数开头添加 H5 分支**
打开 `mini-program/src/services/shortNovel.js`,定位到第 16 行的 `startNovelStream` 函数,将其修改为:
```javascript
export const startNovelStream = ({ query, onEvent, onError }) => {
// H5 环境:使用原生 fetch + ReadableStream
if (isH5) {
return h5NovelStream(
`${getApiBaseUrl()}/shortNovel/stream`,
{ query },
onEvent,
onError
)
}
// 小程序环境:使用 uni.request
let chunkProcessed = false
const task = uni.request({
url: `${getApiBaseUrl()}/shortNovel/stream`,
method: 'POST',
data: { query },
header: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
...getAuthHeader()
},
enableChunked: true,
timeout: SSE_TIMEOUT_MS,
success: (res) => {
if (res.statusCode >= 400) {
onError?.(res.data?.message || '请求失败')
return
}
// 仅在 chunk 未处理时才处理完整 data(避免 H5 双重消费)
if (!chunkProcessed && typeof res.data === 'string' && res.data) {
consumeSseText(res.data, onEvent, onError)
}
},
fail: (error) => {
onError?.(error.errMsg || '网络请求失败')
}
})
task?.onChunkReceived?.((res) => {
chunkProcessed = true
try {
const text = decodeChunk(res.data)
consumeSseText(text, onEvent, onError)
} catch (error) {
onError?.(error.message || '流式解析失败')
}
})
return task
}
```
---
## Task 3: 修改 followupStream 函数支持 H5 环境
**Files:**
- Modify: `mini-program/src/services/shortNovel.js:68-106`
- [ ] **Step 1: 在 followupStream 函数开头添加 H5 分支**
打开 `mini-program/src/services/shortNovel.js`,定位到第 68 行的 `followupStream` 函数,将其修改为:
```javascript
export const followupStream = ({ sessionId, action, payload, originalQuery, onEvent, onError }) => {
// H5 环境:使用原生 fetch + ReadableStream
if (isH5) {
return h5NovelStream(
`${getApiBaseUrl()}/shortNovel/followup`,
{ sessionId, action, payload, originalQuery },
onEvent,
onError
)
}
// 小程序环境:使用 uni.request
let chunkProcessed = false
const task = uni.request({
url: `${getApiBaseUrl()}/shortNovel/followup`,
method: 'POST',
data: { sessionId, action, payload, originalQuery },
header: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
...getAuthHeader()
},
enableChunked: true,
timeout: SSE_TIMEOUT_MS,
success: (res) => {
if (res.statusCode >= 400) {
onError?.(res.data?.message || '请求失败')
return
}
if (!chunkProcessed && typeof res.data === 'string' && res.data) {
consumeSseText(res.data, onEvent, onError)
}
},
fail: (error) => {
onError?.(error.errMsg || '网络请求失败')
}
})
task?.onChunkReceived?.((res) => {
chunkProcessed = true
try {
const text = decodeChunk(res.data)
consumeSseText(text, onEvent, onError)
} catch (error) {
onError?.(error.message || '流式解析失败')
}
})
return task
}
```
---
## Task 4: 构建验证
**Files:**
- 无文件修改,纯构建验证
- [ ] **Step 1: 构建小程序验证语法正确**
```bash
cd mini-program
npm run build:mp-weixin
```
预期输出:`DONE Build complete.`
如果构建失败,检查 `shortNovel.js` 的语法错误并修复。
- [ ] **Step 2: 启动 H5 开发服务器**
```bash
cd mini-program
npm run dev:h5
```
预期输出:服务器在 `http://localhost:5284` 启动
---
## Task 5: 浏览器验证 - H5 模式
**Files:**
- 无文件修改,纯验证任务
- [ ] **Step 1: 访问 H5 页面**
使用浏览器打开 `http://localhost:5284`,登录后进入"心愿实现"页面。
- [ ] **Step 2: 触发 stream 请求**
1. 输入心愿文本(例如:"我想写一个关于时间旅行的故事")
2. 点击生成按钮
3. **验证**:页面进入等待状态
- [ ] **Step 3: 检查 Network 面板**
打开浏览器 DevTools 的 Network 面板:
1. 找到 `/shortNovel/stream` 请求
2. **验证**
- 请求状态为 200(不是 `(failed)`
- 请求在大约 67 秒后仍然保持连接(不会 60 秒后中断)
- 收到 `status` 事件
- 收到 `clarification_card` 事件
- [ ] **Step 4: 检查 Console 面板**
打开 Console 面板,验证:
- 没有 "请求超时" 错误
- 没有 fetch 相关错误
- 正常接收到所有 SSE 事件
- [ ] **Step 5: 验证澄清卡片显示**
确认页面上正确显示澄清卡片,用户可以正常交互(选项可点击切换)。
---
## Task 6: 验证小程序模式不受影响
**Files:**
- 无文件修改,纯验证任务
- [ ] **Step 1: 在微信开发者工具中打开小程序**
1. 打开微信开发者工具
2. 导入 `mini-program/unpackage/dist/dev/mp-weixin` 目录
- [ ] **Step 2: 测试小说生成流程**
1. 进入"心愿实现"页面
2. 输入心愿文本
3. 完成澄清卡片、大纲确认等流程
4. **验证**:原有 uni.request 路径正常工作
- [ ] **Step 3: 检查 Network 请求**
在小程序调试器中查看网络请求:
- `/shortNovel/stream` 请求状态正常
- 事件正常接收
---
## Task 7: 验证服务器日志
**Files:**
- 无文件修改,纯验证任务
- [ ] **Step 1: 下载服务器日志**
```bash
cd G:/IdeaProjects/emotion-museun
python tools/download-server-log.py latest
```
- [ ] **Step 2: 搜索 Broken pipe 错误**
```bash
python tools/download-server-log.py grep "Broken pipe" 10
```
**验证**:H5 模式测试后,服务器日志中不再出现 `Broken pipe` 错误。
- [ ] **Step 3: 验证 SSE 事件正常转发**
```bash
python tools/download-server-log.py grep "ShortNovel SSE" 20
```
**验证**:日志显示完整的事件处理流程:
- 开始读取上游响应
- 处理 status 事件
- 处理 clarification_card 事件
- 完成读取上游响应
---
## Task 8: 提交修改
**Files:**
- Modify: `mini-program/src/services/shortNovel.js`
- [ ] **Step 1: 检查文件改动**
```bash
cd G:/IdeaProjects/emotion-museun
git diff mini-program/src/services/shortNovel.js
```
**验证**:diff 显示添加了 H5 路径实现,保持了原有的 uni.request 逻辑。
- [ ] **Step 2: 提交修改**
```bash
git add mini-program/src/services/shortNovel.js
git commit -m "fix: 修复 H5 模式 stream 请求 60 秒超时问题
- 在 H5 环境使用 fetch + ReadableStream + AbortController
- 支持自定义 300 秒超时
- 小程序环境保持原有 uni.request 逻辑不变"
```
---
## 完成标准
1.`shortNovel.js` 添加了 H5 路径实现(`h5NovelStream``h5ConsumeSseText`
2.`startNovelStream``followupStream` 根据环境自动分发
3. ✅ 小程序构建成功,无语法错误
4. ✅ H5 模式测试通过,stream 请求 67 秒后仍能正常接收事件
5. ✅ 浏览器 Network 面板不再显示 `(failed)` 状态
6. ✅ 小程序模式验证通过,原有功能不受影响
7. ✅ 服务器日志中 `Broken pipe` 错误消失
8. ✅ 修改提交到 git
---
## 风险评估与缓解
### 风险 1:浏览器兼容性
**检测方法**:在目标浏览器中运行 H5 开发服务器,触发 stream 请求
**缓解措施**:通过 `typeof window.fetch === 'function'` 检测,对不支持的环境降级或显示明确错误。
### 风险 2H5 环境检测失败
**检测方法**:如果 `isH5` 检测错误,可能在 H5 环境下走了小程序分支
**缓解措施**:检测条件同时检查 `typeof window !== 'undefined'``typeof window.fetch === 'function'`,确保只在现代浏览器中启用 H5 路径。
### 风险 3abort 接口不兼容
**检测方法**:检查 ScriptView.vue 是否调用 `task.abort()` 方法
**缓解措施**H5 实现返回 `{ abort: () => {...} }` 对象,与 uni.request 任务对象的 `abort` 方法对齐。如果 ScriptView.vue 使用其他属性,需要调整。
### 风险 4SSE 格式差异
**检测方法**:观察服务器发送的 SSE 事件格式
**缓解措施**:使用 `dataBuffer` 累积 `data:` 行,遇到空行才解析,符合 SSE 规范。如果某些事件不带空行分隔,可能需要调整解析逻辑。
---
## 自检清单
实施前请确认:
- [ ] 已阅读设计文档 `docs/superpowers/specs/2026-07-22-stream-timeout-fix-design.md`
- [ ] 已阅读 `mini-program/src/services/shortNovel.js` 当前实现
- [ ] 理解 H5 环境和 mp-weixin 环境的差异
- [ ] 准备好本地 H5 开发服务器(端口 5284)
@@ -0,0 +1,135 @@
# 详情页澄清选项卡显示不全修复 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复后端 `forwardSse` 首次 stream 中第一个 clarification_card 因 sessionId 为 null 不累积的 bug,使详情页澄清选项卡数量与生成页完全一致。
**Architecture:**`ShortNovelServiceImpl.forwardSse` 的事件循环中,每次解析 SSE 事件后,从事件**顶层**取 `session_id` 同步 `currentSessionId[0]`(与前端 `ScriptView.vue:1805` 取 sessionId 的方式对齐)。这样无论 status 事件是否在 payload 内携带 session_id,只要事件顶层有 session_id,首次 stream 的 `currentSessionId[0]` 就能被正确设置,首次 stream 里的 `clarification_card` 就能被累积。
**Tech Stack:** Java 17 / Spring Boot 2.7.18 / fastjson2 / OkHttp / MySQL / Maven / Python 部署脚本 / H5 端到端验收
## Global Constraints
- 编译命令:`mvn clean install -DskipTests`(禁止 `mvn clean compile`
- 部署命令:`python deploy.py backend`(根目录脚本)
- 部署前必须本地编译通过
- 部署后必须通过 H5 端到端验收(Console 0 新增错误 + Network 接口正常)
- 注释必须使用中文
- 禁止任何形式的 mock、兜底、默认值掩盖错误
- 只改后端 1 个方法(`forwardSse`),零前端改动,零数据库变更
---
## Task 1: 后端修复 forwardSse 顶层取 session_id
**Files:**
- Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:304-308``forwardSse` 方法的事件循环内,status 处理之前)
**Interfaces:**
- Consumes: 上游 SSE 事件 JSON 结构(事件顶层 `session_id` 字段与 `type` 同级)
- Produces: `currentSessionId[0]` 在首次 stream 里也能被正确设置(不再是 null)
### 背景
- **根因**:上游 SSE 事件的 `session_id` 在事件**顶层**(与 `type` 同级),但后端 `forwardSse` 行 312-314 从 `payload.getString("session_id")` 取,取不到。导致首次 stream 的 `currentSessionId[0]` 保持 null`knownSessionId=null`)。
- **数据证据**DB 中 3 个有 clarification 的 conversation 全部是 `clarification_question=2 + clarification_answer=3`。第一个 `clarification_question``currentSessionId[0]==null` 不累积。
- **修复思路**:在 status 处理之前,从事件顶层取 `session_id` 同步 `currentSessionId[0]`(与前端 `ScriptView.vue:1805-1806` 逻辑对齐)。
### 步骤
- [ ] **Step 1: 在 forwardSse 事件循环中插入从顶层取 session_id 的代码**
打开 `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java`,定位到 `forwardSse` 方法内的事件循环。当前代码(行 304-311):
```java
try {
JSONObject event = JSON.parseObject(dataStr);
String type = event.getString("type");
long eventTimestamp = System.currentTimeMillis();
log.info("[ShortNovel SSE] 处理事件: type={}, timestamp={}", type, eventTimestamp);
// 拦截 status 事件,缓存 sessionId → originalQuery 映射(供 followup 兜底)
if ("status".equals(type)) {
```
`log.info(...)` 之后、`// 拦截 status 事件` 注释之前,**插入以下 5 行代码**:
```java
// 从事件顶层取 session_id 同步 sessionId
// 上游事件结构与前端对齐:session_id 与 type 同级,不在 payload 内
// 修复:首次 stream 里 status 处理从 payload 取不到 session_id,导致 currentSessionId 保持 null
// 第一个 clarification_card 因 sessionId 为 null 不累积,详情页少显示第一个选项卡
String topSessionId = event.getString("session_id");
if (topSessionId != null && !topSessionId.isEmpty()) {
currentSessionId[0] = topSessionId;
}
```
插入后整体结构(行 304-320):
```java
try {
JSONObject event = JSON.parseObject(dataStr);
String type = event.getString("type");
long eventTimestamp = System.currentTimeMillis();
log.info("[ShortNovel SSE] 处理事件: type={}, timestamp={}", type, eventTimestamp);
// 从事件顶层取 session_id 同步 sessionId
// 上游事件结构与前端对齐:session_id 与 type 同级,不在 payload 内
// 修复:首次 stream 里 status 处理从 payload 取不到 session_id,导致 currentSessionId 保持 null
// 第一个 clarification_card 因 sessionId 为 null 不累积,详情页少显示第一个选项卡
String topSessionId = event.getString("session_id");
if (topSessionId != null && !topSessionId.isEmpty()) {
currentSessionId[0] = topSessionId;
}
// 拦截 status 事件,缓存 sessionId → originalQuery 映射(供 followup 兜底)
if ("status".equals(type)) {
```
- [ ] **Step 2: 本地编译验证**
Run: `cd server && mvn clean install -DskipTests`
Expected: `BUILD SUCCESS`,零错误。
- [ ] **Step 3: 部署到远程服务器**
Run: `python deploy.py backend`
Expected: 部署脚本输出"部署成功",服务重启完成。
如果部署脚本在 Windows 报 PATH 问题,手动执行:
```bash
scp server/target/emotion-single-1.0.0.jar root@101.200.208.45:/data/programs/emotion-museum/
ssh root@101.200.208.45 "cd /data/programs/emotion-museum && ./restart.sh emotion-museum-single"
```
- [ ] **Step 4: H5 端到端验收**
1. 启动 mini-program H5`python dev-services.py start mini-program`(端口 5180
2. 浏览器访问 `http://localhost:5180/#/pages/main/index?tab=script`
3. 手动完成一次新的小说生成(包含至少 2 轮澄清)
4. 生成过程中**记录生成页显示的选项卡数量 N**
5. 生成完成后,进入该剧本的详情页(通过列表页点"历史"进入 ScriptLibraryView,再点击对应剧本)
6. 验证详情页显示 **N 个澄清选项卡**(与生成页完全一致)
7. 浏览器 Console 检查:0 新增错误(既有的 `uni.getRecorderManager` 错误除外)
8. Network 面板:`listByConversation` 接口响应中 `clarification_question` 类型消息数量 = 生成页澄清轮数
- [ ] **Step 5: 提交代码**
```bash
cd G:\IdeaProjects\emotion-museun
git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java
git commit -m "fix(server): 修复 forwardSse 首次 stream 第一个 clarification_card 不累积的 bug
上游 SSE 事件的 session_id 在事件顶层(与 type 同级),后端原从 payload.session_id 取导致首次 stream 取不到,
currentSessionId 保持 null,第一个 clarification_card 不累积。
改为从事件顶层取 session_id 同步 currentSessionId(与前端 ScriptView.vue:1805 逻辑对齐)。
修复后新剧本详情页澄清选项卡数量与生成页完全一致。"
```
### 验收标准
- ✅ 本地 `mvn clean install` 编译通过
- ✅ 部署到远程服务器成功
- ✅ 新剧本详情页澄清选项卡数量 = 生成页澄清选项卡数量
- ✅ Console 0 新增错误
- ✅ 老剧本(3 个已有 conversation)保持现状,仍显示 2 个 card(数据已永久丢失,符合预期)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,605 @@
# 小说消息操作按钮恢复实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 恢复小程序小说生成页和详情页丢失的"复制、播放、折叠/展开"功能,每条 novel 消息下方添加 3 个操作按钮。
**Architecture:** 在 ScriptView.vue 和 ScriptDetailView.vue 中为 `kind === 'novel'` 的消息添加按钮组 UI(折叠/展开、复制、播放)。ScriptView 复用已有函数,ScriptDetailView 新增相同逻辑。按钮组样式统一,参考 MessageCard 的 result-actions 样式。
**Tech Stack:** Vue 3 Composition API / UniApp / Pinia / uni.setClipboardData / useTtsPlayer composable / H5 端到端验收
## Global Constraints
- 所有注释必须使用中文
- 样式单位必须使用 rpx(小程序移动端)
- 禁止任何形式的 mock、兜底、默认值掩盖错误
- 每个按钮必须有真实实现,不能有任何占位或 mock 功能
- 修改后必须通过 H5 端到端验收(Console 0 新增错误)
- 部署使用 `python deploy.py backend`(后端)或热加载(前端)
- 埋点必须完整触发:`script_message_collapse_toggle``script_message_copy_click``script_message_tts_click`
---
## Task 1: ScriptView 恢复按钮组 UI
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue:180-187`novel 消息渲染部分)
- Modify: `mini-program/src/pages/main/ScriptView.vue:3500+`(样式部分)
**Interfaces:**
- Consumes: 已有函数 `getMessageDisplayContent(msg)``toggleMessageCollapse(msg)``copyMessageContent(msg)``playMessageTts(msg)``isMessageCollapsed(msg)`
- Produces: novel 消息下方显示 3 个按钮(折叠/展开、复制、播放),功能正常
### 背景
ScriptView.vue 中所有相关函数已存在(行 399-447),但模板中按钮组 UI 在 commit 2c778fa 中被删除。本任务恢复按钮组 UI。
### 步骤
- [ ] **Step 1: 定位 novel 消息渲染部分**
打开 `mini-program/src/pages/main/ScriptView.vue`,找到 `kind === 'novel'` 的消息渲染部分(约行 180-187):
```vue
<view v-else-if="msg.kind === 'novel'" class="chat-bubble system novel-bubble">
<Markdown :content="msg.content" />
</view>
```
- [ ] **Step 2: 修改 Markdown 渲染,使用折叠内容**
`<Markdown :content="msg.content" />` 改为:
```vue
<Markdown :content="getMessageDisplayContent(msg)" />
```
这样折叠时只显示前 200 字符。
- [ ] **Step 3: 添加折叠/展开按钮**
`<Markdown>` 下方添加折叠/展开按钮:
```vue
<view v-if="msg.content && msg.content.length > 200" class="collapse-toggle-row" @click="toggleMessageCollapse(msg)">
<text class="collapse-toggle-text">{{ isMessageCollapsed(msg) ? '展开全文' : '收起全文' }}</text>
<view class="collapse-chevron" :class="{ down: isMessageCollapsed(msg) }">
<view></view>
<view></view>
</view>
</view>
```
- [ ] **Step 4: 添加操作按钮组**
在折叠/展开按钮下方添加操作按钮组(复制、播放):
```vue
<view class="message-actions">
<button class="action-btn" @click="copyMessageContent(msg)">
<text>📋 复制</text>
</button>
<button class="action-btn primary" @click="playMessageTts(msg)">
<text class="action-icon">{{ ttsPlayer.playing.value ? 'Ⅱ' : '▶' }}</text>
<text>{{ ttsPlayer.playing.value ? '暂停' : '播放' }}</text>
</button>
</view>
```
注意:只有 2 个按钮(复制、播放),折叠/展开按钮已在上方独立显示。
- [ ] **Step 5: 添加按钮组样式**
`<style scoped>` 部分(约行 3500+)添加以下样式:
```css
/* 折叠/展开按钮行 */
.collapse-toggle-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
margin-top: 20rpx;
padding: 16rpx 0;
border-radius: 999rpx;
background: rgba(88, 28, 135, 0.12);
border: 1rpx solid rgba(192, 132, 252, 0.28);
color: rgba(246, 230, 255, 0.96);
font-size: 26rpx;
font-weight: 700;
}
.collapse-toggle-row:active {
transform: scale(0.98);
opacity: 0.92;
}
.collapse-toggle-text {
line-height: 1;
}
/* 折叠箭头图标 */
.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;
}
/* 操作按钮组 */
.message-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 20rpx;
}
.action-btn {
height: 72rpx;
min-height: 72rpx;
padding: 0 8rpx;
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
color: #e8ccff;
font-size: 26rpx;
font-weight: 700;
line-height: 1.15;
text-align: center;
white-space: normal;
box-sizing: border-box;
background: rgba(88, 28, 135, 0.18);
border: 1rpx solid rgba(192, 132, 252, 0.35);
}
.action-btn.primary {
color: #fff;
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;
}
```
- [ ] **Step 6: H5 验收**
启动 H5 并验收:
```bash
python dev-services.py start mini-program
```
浏览器访问 `http://localhost:5180/#/pages/main/index?tab=script`,进入一个已有小说的生成页:
验收项:
- ✅ 小说正文下方有折叠/展开按钮(内容超过 200 字时显示)
- ✅ 点击"展开全文":内容完整显示,按钮变为"收起全文"
- ✅ 点击"收起全文":内容截断显示前 200 字,按钮变为"展开全文"
- ✅ 小说正文下方有 2 个操作按钮:复制、播放
- ✅ 点击"复制"Toast 提示"已复制",剪贴板中有内容
- ✅ 点击"播放"TTS 开始播放,按钮变为"暂停"
- ✅ 浏览器 Console 检查:0 新增错误
- [ ] **Step 7: Commit**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "feat(mini-program): ScriptView 恢复小说消息操作按钮组
- 为 kind === 'novel' 的消息添加折叠/展开、复制、播放按钮
- 复用已有函数:getMessageDisplayContent、toggleMessageCollapse、copyMessageContent、playMessageTts
- 添加 .collapse-toggle-row、.message-actions、.action-btn 样式
- H5 验收通过,Console 0 新增错误"
```
### 验收标准
- ✅ 每条 novel 消息下方有折叠/展开按钮(内容超过 200 字时显示)
- ✅ 折叠/展开功能正常:折叠时显示前 200 字,展开时显示完整内容
- ✅ 每条 novel 消息下方有 2 个操作按钮:复制、播放
- ✅ 复制功能正常:点击后 Toast 提示"已复制"
- ✅ 播放功能正常:点击后 TTS 开始播放,按钮变为"暂停"
- ✅ Console 无新增错误
---
## Task 2: ScriptDetailView 新增函数和按钮组 UI
**Files:**
- Modify: `mini-program/src/pages/main/ScriptDetailView.vue:96-311`script 部分)
- Modify: `mini-program/src/pages/main/ScriptDetailView.vue:74-76`novel 消息渲染部分)
- Modify: `mini-program/src/pages/main/ScriptDetailView.vue:313+`(样式部分)
**Interfaces:**
- Consumes: `ttsPlayer` composable(已有)、`script` ref(已有)
- Produces: novel 消息下方显示 3 个按钮(折叠/展开、复制、播放),功能正常
### 背景
ScriptDetailView.vue 中完全没有相关函数和按钮组 UI。本任务新增所有函数 + 模板按钮组 + 样式。
### 步骤
- [ ] **Step 1: 新增 collapsedMessageIds ref**
打开 `mini-program/src/pages/main/ScriptDetailView.vue`,在 `<script setup>` 部分(约行 96-110)添加:
```javascript
const collapsedMessageIds = ref({})
```
放在 `const resultMessages = ref([])` 下方。
- [ ] **Step 2: 新增 isMessageCollapsed 函数**
`collapsedMessageIds` 下方添加:
```javascript
/**
* 判断消息是否折叠
*/
const isMessageCollapsed = (message) => {
return Boolean(collapsedMessageIds.value[message.id])
}
```
- [ ] **Step 3: 新增 getMessageDisplayContent 函数**
`isMessageCollapsed` 下方添加:
```javascript
/**
* 获取消息显示内容(折叠时截断)
*/
const getMessageDisplayContent = (message) => {
const content = String(message?.content || '')
if (!isMessageCollapsed(message)) return content
if (content.length <= 200) return content
return `${content.slice(0, 200)}...`
}
```
- [ ] **Step 4: 新增 toggleMessageCollapse 函数**
`getMessageDisplayContent` 下方添加:
```javascript
/**
* 切换消息折叠状态
*/
const toggleMessageCollapse = (message) => {
collapsedMessageIds.value = {
...collapsedMessageIds.value,
[message.id]: !isMessageCollapsed(message)
}
analytics.track('script_message_collapse_toggle', {
message_id: message?.id || '',
collapsed: collapsedMessageIds.value[message.id]
}, { eventType: 'script', pagePath })
}
```
- [ ] **Step 5: 新增 copyMessageContent 函数**
`toggleMessageCollapse` 下方添加:
```javascript
/**
* 复制消息内容到剪贴板
*/
const copyMessageContent = (message) => {
const content = String(message?.content || '')
if (!content.trim()) {
uni.showToast({ title: '暂无可复制内容', icon: 'none' })
return
}
uni.setClipboardData({
data: content,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
}
})
analytics.track('script_message_copy_click', {
message_id: message?.id || '',
content_length: content.length
}, { eventType: 'script', pagePath })
}
```
- [ ] **Step 6: 新增 playMessageTts 函数**
`copyMessageContent` 下方添加:
```javascript
/**
* 播放消息 TTS 音频
*/
const playMessageTts = (message) => {
const scriptId = script.value?.id || ''
ttsPlayer.playSource(scriptId)
analytics.track('script_message_tts_click', {
message_id: message?.id || '',
script_id: scriptId
}, { eventType: 'script', pagePath })
}
```
- [ ] **Step 7: 定位 novel 消息渲染部分**
在模板中找到 `kind === 'novel'` 的消息渲染部分(约行 74-76):
```vue
<view v-else-if="msg.kind === 'novel'" class="chat-bubble system novel-bubble">
<Markdown :content="msg.content" />
</view>
```
- [ ] **Step 8: 修改 Markdown 渲染,使用折叠内容**
`<Markdown :content="msg.content" />` 改为:
```vue
<Markdown :content="getMessageDisplayContent(msg)" />
```
- [ ] **Step 9: 添加折叠/展开按钮**
`<Markdown>` 下方添加折叠/展开按钮:
```vue
<view v-if="msg.content && msg.content.length > 200" class="collapse-toggle-row" @click="toggleMessageCollapse(msg)">
<text class="collapse-toggle-text">{{ isMessageCollapsed(msg) ? '展开全文' : '收起全文' }}</text>
<view class="collapse-chevron" :class="{ down: isMessageCollapsed(msg) }">
<view></view>
<view></view>
</view>
</view>
```
- [ ] **Step 10: 添加操作按钮组**
在折叠/展开按钮下方添加操作按钮组(复制、播放):
```vue
<view class="message-actions">
<button class="action-btn" @click="copyMessageContent(msg)">
<text>📋 复制</text>
</button>
<button class="action-btn primary" @click="playMessageTts(msg)">
<text class="action-icon">{{ ttsPlayer.playing.value ? 'Ⅱ' : '▶' }}</text>
<text>{{ ttsPlayer.playing.value ? '暂停' : '播放' }}</text>
</button>
</view>
```
- [ ] **Step 11: 添加按钮组样式**
`<style scoped>` 部分(约行 313+)添加以下样式(与 ScriptView 完全一致):
```css
/* 折叠/展开按钮行 */
.collapse-toggle-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
margin-top: 20rpx;
padding: 16rpx 0;
border-radius: 999rpx;
background: rgba(88, 28, 135, 0.12);
border: 1rpx solid rgba(192, 132, 252, 0.28);
color: rgba(246, 230, 255, 0.96);
font-size: 26rpx;
font-weight: 700;
}
.collapse-toggle-row:active {
transform: scale(0.98);
opacity: 0.92;
}
.collapse-toggle-text {
line-height: 1;
}
/* 折叠箭头图标 */
.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;
}
/* 操作按钮组 */
.message-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 20rpx;
}
.action-btn {
height: 72rpx;
min-height: 72rpx;
padding: 0 8rpx;
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
color: #e8ccff;
font-size: 26rpx;
font-weight: 700;
line-height: 1.15;
text-align: center;
white-space: normal;
box-sizing: border-box;
background: rgba(88, 28, 135, 0.18);
border: 1rpx solid rgba(192, 132, 252, 0.35);
}
.action-btn.primary {
color: #fff;
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;
}
```
- [ ] **Step 12: H5 验收**
H5 服务应已在运行(端口 5180),浏览器访问详情页:
从列表页点击一个已有小说的剧本,进入详情页。
验收项:
- ✅ 小说正文下方有折叠/展开按钮(内容超过 200 字时显示)
- ✅ 点击"展开全文":内容完整显示,按钮变为"收起全文"
- ✅ 点击"收起全文":内容截断显示前 200 字,按钮变为"展开全文"
- ✅ 小说正文下方有 2 个操作按钮:复制、播放
- ✅ 点击"复制"Toast 提示"已复制",剪贴板中有内容
- ✅ 点击"播放"TTS 开始播放,按钮变为"暂停"
- ✅ 浏览器 Console 检查:0 新增错误
- [ ] **Step 13: Commit**
```bash
git add mini-program/src/pages/main/ScriptDetailView.vue
git commit -m "feat(mini-program): ScriptDetailView 新增小说消息操作按钮组
- 新增 collapsedMessageIds ref + 5 个函数(isMessageCollapsed、getMessageDisplayContent、toggleMessageCollapse、copyMessageContent、playMessageTts
- 为 kind === 'novel' 的消息添加折叠/展开、复制、播放按钮
- 添加 .collapse-toggle-row、.message-actions、.action-btn 样式
- H5 验收通过,Console 0 新增错误"
```
### 验收标准
- ✅ 每条 novel 消息下方有折叠/展开按钮(内容超过 200 字时显示)
- ✅ 折叠/展开功能正常:折叠时显示前 200 字,展开时显示完整内容
- ✅ 每条 novel 消息下方有 2 个操作按钮:复制、播放
- ✅ 复制功能正常:点击后 Toast 提示"已复制"
- ✅ 播放功能正常:点击后 TTS 开始播放,按钮变为"暂停"
- ✅ Console 无新增错误
- ✅ 埋点正常触发:`script_message_collapse_toggle``script_message_copy_click``script_message_tts_click`
---
## 整体验收
两个 Task 完成后,进行整体验收:
1. **生成页验收**
- 访问 `http://localhost:5180/#/pages/main/index?tab=script`
- 进入一个已有小说的生成页
- 验证折叠/展开、复制、播放功能
- Console 0 新增错误
2. **详情页验收**
- 从列表页点击一个已有小说的剧本,进入详情页
- 验证折叠/展开、复制、播放功能
- Console 0 新增错误
3. **样式一致性**
- 两个页面的按钮组样式完全一致
- 按钮布局、颜色、圆角、间距一致
4. **埋点验证**
- 浏览器 Console 中查看埋点日志
- 确认 `script_message_collapse_toggle``script_message_copy_click``script_message_tts_click` 正常触发
---
## 风险与注意事项
1. **TTS 播放的是整个小说**`ttsPlayer.playSource(scriptId)` 播放的是整个小说的音频,不是单条消息的音频。这是历史设计,保持一致。
2. **折叠阈值**:折叠时显示前 200 字符。这个阈值可以根据实际需求调整。
3. **样式一致性**:两个页面的按钮组样式必须保持一致,直接复制 ScriptView 的样式到 ScriptDetailView。
4. **性能考虑**`collapsedMessageIds` 是响应式对象,每次切换折叠状态都会触发重新渲染。对于大量消息的场景,可能需要优化(当前场景下消息数量有限,不构成问题)。
5. **热加载**:前端修改后利用热加载自动生效,不需要重启。如果热加载不生效,使用 `python dev-services.py restart mini-program`
---
## 提交历史
完成所有 Task 后,提交历史应为:
```
<hash> feat(mini-program): ScriptDetailView 新增小说消息操作按钮组
<hash> feat(mini-program): ScriptView 恢复小说消息操作按钮组
```
@@ -0,0 +1,106 @@
---
author: Peanut
created_at: 2026-06-28
purpose: 修复生产环境首页 404,删除 nginx 配置中显式阻断根路径的 location 块
---
# 修复 nginx 根路径 404 设计
## 背景
生产环境 `https://lifescript.happylifeos.com/` 访问首页返回 404 Not Found,但:
- 前端文件 `/data/www/emotion-museum/index.html` 实际已正确部署
- 子路径 `https://lifescript.happylifeos.com/emotion-museum-admin/``/life-script/``/emotion-museum/` 可以正常访问
- 后端 API 代理 `/api` 正常工作
## 根因(实际)
**真实生效的 nginx 配置不在 `conf/emotion-museum.conf`**,而是在 aaPanel 托管的:
`/www/server/panel/vhost/nginx/emotion-museum.conf`
原因:nginx 主配置 `/www/server/nginx/conf/nginx.conf` 的 include 顺序是:
```nginx
include /www/server/panel/vhost/nginx/*.conf;
```
它只 include aaPanel 的 vhost 目录。`/etc/nginx/sites-available/` 是部署脚本的"期望"路径,但**从未被 include**,所以 `deploy.py nginx` 实际上一直没真正生效。
真实配置中第 31-33 行有一个显式阻断根路径的 exact match
```nginx
location = / {
return 404;
}
```
并且前端在真实配置中映射的路径是 `/emotion-museum/`(不是根路径):
```nginx
location /emotion-museum/ {
alias /data/www/emotion-museum/;
...
try_files $uri $uri/ /emotion-museum/index.html;
}
```
所以访问 `/` 时,被 `location = / { return 404; }` 精确匹配拦截,根本不会走到任何 location 块。
## 修复方案
### 第一层:修改真实生效的 aaPanel 配置
直接 ssh 编辑 `/www/server/panel/vhost/nginx/emotion-museum.conf`,把:
```nginx
location = / {
return 404;
}
```
改成:
```nginx
location = / {
rewrite ^ /emotion-museum/ permanent;
}
```
这样根路径会被 301 永久重定向到 `/emotion-museum/`(前端实际部署位置)。
然后 `nginx -t && nginx -s reload` 让配置生效。
### 第二层:同步更新仓库内 `conf/emotion-museum.conf`
保持仓库内的配置文件与真实服务器一致(作为基线参考)。同步修改:
1. 删除之前的 `location / { alias /data/www/emotion-museum/; ... }` 通用块(真实配置没有)
2. 新增 `location /emotion-museum/ { ... }` 前端块(与真实配置对齐)
3. `location = /` 改为 `rewrite ^ /emotion-museum/ permanent;`
**注意**:虽然仓库内配置现在不被真实 nginx include,但保持同步是为了:
- 作为文档,记录服务器实际配置
- 未来如果切回 sites-available 部署模式,能直接生效
- 避免"仓库配置"和"服务器配置"分叉导致混乱
## 验证步骤
1. ssh 直接编辑服务器上的 `/www/server/panel/vhost/nginx/emotion-museum.conf`,改 `location = /`
2. `nginx -t` 验证语法
3. `nginx -s reload` 重载配置
4. 浏览器访问 `https://lifescript.happylifeos.com/` 确认 301 → `https://lifescript.happylifeos.com/emotion-museum/`
5. `/emotion-museum/` 正常返回前端页面(200
6. DevTools Network 无 404、Console 无报错
7. 其他子路径(`/emotion-museum-admin/``/life-script/``/api/``/ws``/health`)不受影响
8. 同步更新 `conf/emotion-museum.conf` 作为仓库基线
9. 提交 git commit
## 风险与回退
- **影响面小**:只改 1 个 location 块(3 行),其他 location 块完全不动
- **回退方案**:若出问题,把 `rewrite ^ /emotion-museum/ permanent;` 改回 `return 404;` 即可立即回滚
- **不会冲突**`/emotion-museum/``/emotion-museum-admin/``/life-script/` 都有自己的 location 块,`location = /` 精确匹配只对根路径生效
## 长期改进(不在本次范围)
`deploy.py nginx` 当前部署到 `/etc/nginx/sites-available/`,但服务器主配置 include 的是 aaPanel 路径,所以部署脚本实际上一直无效。后续应该:
-`deploy.py nginx` 同时更新 `/www/server/panel/vhost/nginx/emotion-museum.conf`
- 或者在 nginx 主配置中增加 `include /etc/nginx/sites-enabled/*.conf;`
- 本次不处理,单独跟踪
@@ -0,0 +1,84 @@
---
author: Peanut
created_at: 2026-06-28
purpose: 回滚 ScriptView 的临时 conversationId 正则短路保护,修复历史剧本消息卡片不显示的回归
---
# 回滚 ScriptView 临时 ID 正则短路保护
## 背景
commit 19337b9"ScriptView loadMessages 对临时 conversationId 正则短路保护")引入了一个回归 bug:
用户从"历史剧本"列表点进详情再点"继续"进入 ScriptView**看不到完整的消息卡片**。
## 根因
DB 中历史剧本的 `conversation_id` 字段值就是 `conv-<ts>-<rand>` 格式(前端在早期版本自造后写入):
```
conv-1782552967659-615gen
conv-1782228787632-fkifia
conv-1781497026473-7l8jco
...
```
这些 conversationId 完全匹配 commit 19337b9 加的正则 `^conv-\d+-[a-z0-9]{6}$`
并且这些 conversation 在 `t_conversation` 表中大多没有对应记录(`current_version_message_id` 字段全为 NULL,佐证了当时 conversation 没有完整落库)。
用户打开历史剧本的 ScriptView 时:
1. 从 store 拿到 script`conversationId = script.conversationId` = `conv-1782552967659-615gen`
2. 进入 `loadMessages()`
3. 正则匹配 + `backedByScript=false`script 上没有 conversationId 背书?错,有背书但被误判)
4. 实际上 `currentResult.value.conversationId``openScriptChat` 里被赋值为 `script.conversationId`,但 `openScriptChat` 是在 ScriptView 页内调用的,详情页 "继续" 按钮走的是 `continueCurrent``uni.reLaunch({ url: '/pages/main/index?tab=script' })`ScriptView 重新初始化时 `currentResult.value` 还未被赋值
5. 正则保护触发 → `messages.value = []`**所有消息卡片不显示**
## 修复方案
**删除** commit 19337b9 在 `mini-program/src/pages/main/ScriptView.vue``loadMessages` 中添加的正则短路保护代码。恢复为:
```javascript
const loadMessages = async () => {
if (!conversationId.value) return
try {
const res = await listMessagesByConversation({ conversationId: conversationId.value, includeVersions: false })
messages.value = res.data || []
const scriptMessages = messages.value.filter(m => m.type === 'script')
if (scriptMessages.length > 0) {
const rootMessage = scriptMessages[0]
const versionRes = await listMessageVersions(rootMessage.id)
versions.value = versionRes.data || []
}
} catch (error) {
uni.showToast({ title: error.message || '加载消息失败', icon: 'none' })
}
}
```
**为什么可以直接回滚**
- commit e3eb1af 已让后端 `listByConversation` 在 conversation 不存在时返回空列表(不抛 500)
- 所以前端调 API 时即使传了"不存在"的 conversationId,也只会得到空数据,不会再 500
- 5xx 噪音问题已由后端修复兜底,前端的正则保护属于过度防御
## 验证步骤
1. 修改 `mini-program/src/pages/main/ScriptView.vue`,删除正则短路保护
2. 启动 H5`python dev-services.py start mini-program`(端口 5180
3. 浏览器访问 `http://localhost:5180`,登录一个有历史剧本的账号
4. 进入任一历史剧本的 ScriptView
5. 验证:
- 不再看到正则保护导致的"消息全空"
- Network 面板有 `/message/listByConversation` 请求
- 响应状态 200body 为 `{"code":200,"data":[]}`conversation 不存在)或有数据
- Console 无报错
## 风险与回退
- **回退方案**:如果回滚后出现 500 噪音,可以再在前端加判断(但后端已兜底,不应再出现)
- **影响面**:仅影响 ScriptView.vue 的 loadMessages 函数,其他页面不受影响
## 长期改进(不在本次范围)
历史剧本的 conversation_id 是前端自造的临时 ID,但被当作"真实 conversationId"写入了 `t_epic_script.conversation_id`。这是数据模型上的不一致:
- 要么在写入 epic_script 时同步创建 t_conversation 记录
- 要么 epic_script.conversation_id 字段只存真实落库的 ID
- 本次不处理,单独跟踪
@@ -0,0 +1,286 @@
---
author: Peanut
created_at: 2026-06-29
purpose: 完整修复 ScriptView 对话模式的按钮缺失问题,补齐 spec 要求的版本/改写/续写/历史版本/删除功能
---
# ScriptView 对话模式按钮完整修复设计
## 背景
`bf6f279 feat: ScriptView 改造为对话中心查看/修改模式` 重构后,对话模式的按钮出现多处丢失:
1. **旧版按钮丢失**`MessageCard``isShortMessage=true`(短消息)分支没有按钮组,而旧版 assistant 消息后面有「复制/换个方向/不像我/继续生成/播放」按钮组(commit `0db434c` 添加)
2. **Spec 功能未实现**`2026-06-28-script-dialogue-design.md` 第 5.4 节要求 AI 剧本消息有「改写 / 续写 / 查看历史版本 / 删除版本」按钮,当前完全没实现
3. **根版本被过滤**`displayMessages``m.type !== 'script' || !m.parentMessageId` 过滤,把根版本 AI 剧本消息丢掉了
4. **版本标签缺失**AI 消息没有 `V1 / V2` 版本号标签和「(当前)」标记
## 设计
### 1. MessageCard.vue 重构
**新增 props**
- `messageType`: `'script' | 'chat' | 'system'`,默认 `'script'`
- `versionLabel`: 版本号显示(如 `V1 (当前)`),script 类型必填,其他类型不传
- `hasChildren`: 是否有子版本(布尔,用于控制「查看历史版本」按钮是否显示)
- `canDelete`: 是否可删除(布尔,当前生效版本不可删除)
- `canRewrite`: 是否可改写(布尔,默认 true)
- `canContinue`: 是否可续写(布尔,默认 true)
**渲染规则**
| messageType | 渲染方式 |
|---|---|
| `'script'` | **永远**走长文本 story-card 分支(不论 `isShortMessage`),因为剧本消息总是需要按钮组 |
| `'chat'``'system'` | 保持原逻辑(短消息气泡 / 长消息 story-card |
**按钮组**(按 messageType 切换):
```
script 消息按钮组:
┌─ 顶部操作(story-head-actions,不变)
│ · 收起/展开
│ · 复制
├─ 版本标签(新增,仅在 versionLabel 有值时显示)
│ · V1 (当前) / V2 / ...
├─ 功能按钮(新增,script 专属)
│ · 改写(emit 'rewrite'
│ · 续写(emit 'continue-script'
│ · 查看历史版本(emit 'view-versions'hasChildren=true 才显示)
│ · 删除版本(emit 'delete-version'canDelete=true 才显示)
└─ 通用操作(保留旧版)
· TTS 播放(emit 'play-tts'
chat 消息按钮组(isShortMessage=false 时):
┌─ 复制
├─ 收起/展开
└─ TTS 播放(emit 'play-tts'
system 消息(isShortMessage=true 默认走气泡):
└─ 不变
```
**新增 emits**
- `rewrite` — 改写按钮
- `continue-script` — 续写按钮(避免和旧 `continue` 语义冲突,但保留旧 `continue` 兼容)
- `view-versions` — 查看历史版本
- `delete-version` — 删除版本
### 2. ScriptView.vue 改动
**改动 2.1:显示根版本**
```js
// 旧
const displayMessages = computed(() => {
return messages.value.filter(m => m.type !== 'script' || !m.parentMessageId)
})
// 新:保留所有消息,根版本作为 chat 列表首条
const displayMessages = computed(() => messages.value)
```
**改动 2.2:传 messageType 给 MessageCard**
```vue
<MessageCard
v-if="message.sender === 'assistant' || message.type === 'script'"
message-type="script"
:version-label="formatVersionLabel(message)"
:has-children="messageHasChildren(message)"
:can-delete="message.id !== currentVersionMessageId"
:can-rewrite="true"
:can-continue="true"
:content="message.content"
:collapsed="isMessageCollapsed(message)"
:content-length="message.content.length"
:is-short-message="false"
:tts-icon="..."
:tts-text="..."
@toggle-collapse="toggleMessageCollapse(message)"
@copy="copyMessageContent(message)"
@rewrite="rewriteMessage(message)"
@continue-script="continueMessage(message)"
@view-versions="viewMessageVersions(message)"
@delete-version="removeVersion(message)"
@play-tts="playMessageTts(message)"
@change-direction="changeDirection"
@not-like-me="notLikeMe"
/>
```
**改动 2.3:版本标签生成函数**
```js
const formatVersionLabel = (message) => {
if (message.type !== 'script') return ''
const versionNum = message.versionNumber || 1
const isCurrent = message.id === currentVersionMessageId.value
return isCurrent ? `V${versionNum} (当前)` : `V${versionNum}`
}
const messageHasChildren = (message) => {
// 通过 versions 数组长度判断(已加载的)或 metadata 推断
return versions.value.some(v => v.parentMessageId === message.id)
|| (message.versionNumber && message.versionNumber > 1)
|| (message.childrenCount && message.childrenCount > 0)
}
```
**改动 2.4:查看历史版本**
新增 `viewMessageVersions(msg)` 函数:
1. 调用 `listMessageVersions(msg.id)`
2.`uni.showActionSheet` 列出所有版本(V1、V2...
3. 用户选择后:
- 如果选的是当前版本:`uni.showToast` 提示「当前已生效」
- 如果选的是其他版本:调用 `switchVersion({ scriptId, messageId: selectedId })` → 更新 `currentVersionMessageId``loadMessages()` 刷新
### 4. 历史剧本兼容层(高优先级)
### 现状
DB 中绝大多数历史剧本的 `conversation_id` 是前端早期自造的临时 ID`conv-<ts>-<rand>` 格式),**`t_conversation` 表中没有对应记录**。
```
329987360219471872 我的人生剧本 → YES (最新剧本,走新流程创建)
f82818b702d5ad4a... 高考 → NO
a3dadb7e85a6c75b... 我高考了 → NO
49ae0f432253aaff... 我中了100w → NO
... 其余 6 条 → NO
```
### 影响分析
| 场景 | 是否受影响 | 原因 |
|---|---|---|
| ScriptDetailView 阅读模式 | ✅ 正常 | 读 `t_epic_script.plotJson`,不依赖 conversation |
| ScriptView `viewMode='read'` 阅读模式 | ✅ 正常 | 通过 `currentScriptContent``currentVersionMessageId` 指向的 messagefallback 到 `plotJson` |
| ScriptView `viewMode='chat'` 聊天模式 | ❌ 异常 | `listByConversation` 返回空,对话历史丢失 |
| 改写 / 续写 / 版本切换 | ❌ 异常 | 需要有效的 conversation 和 message 才能操作 |
### 兼容策略:前端 fallback + 后端自动建表
**改动 A:后端自动建 conversation**
`EpicScriptServiceImpl.getScriptById`(或 `getListByCurrentUser` 返回前)检查:
-`script.conversation_id` 指向的 conversation 不存在 → 自动创建一条 `t_conversation` 记录,写入 `user_id``script_id``type='script'``status='active'`,并回填 `t_epic_script.conversation_id` 为新 ID
**改动 B:前端首次进入聊天模式时,基于 `plotJson` 初始化首条消息**
在 ScriptView.vue 的 `loadMessages` 中:
- 若 API 返回空列表 && `script.plotJson?.fullContent` 存在 → 自动调用 `/message/create` 创建一条 `type='script'` 的 AI 剧本消息(content 来自 plotJson
- 后续改写/续写/版本切换基于这条真实消息操作
**改动 CScriptView 阅读模式 fallback 强化**
当前 `currentScriptContent` 已在 message 不存在时 fallback 到 `plotJson`。需确保:
- `viewMode='read'` 下,版本标签在没有 versions 数据时隐藏(不显示"V0"之类)
- "进入对话修改"按钮在没有 conversation 时仍可点击,触发改动 B 的初始化流程
### 验收标准(历史剧本)
- [ ] 历史剧本在 ScriptDetailView 完整显示标题、summary、content
- [ ] 历史剧本在 ScriptView `viewMode='read'` 完整显示标题、章节内容(无版本标签时 UI 不异常)
- [ ] 历史剧本点击"进入对话修改" → 自动创建 conversation + 初始化首条 AI 消息 → 进入 chat 模式
- [ ] 历史剧本进入 chat 模式后,能看到完整对话历史(首条为用户心愿 + AI 剧本)
- [ ] 历史剧本的"改写/续写/查看历史版本"功能可正常使用
- [ ] 新创建的剧本所有功能不受影响(回归验证)
## 5. 实施顺序
| 顺序 | 任务 | 类型 |
|---|---|---|
| 1 | 后端 `getScriptById` 自动建 conversation | 数据兼容 |
| 2 | 前端 MessageCard 重构 + 按钮组 | 功能补齐 |
| 3 | 前端 ScriptView displayMessages 去掉根版本过滤 | Bug 修复 |
| 4 | 前端 loadMessages 空结果时自动初始化首条消息 | 数据兼容 |
| 5 | 前端 chat 模式传 messageType/versionLabel 等 props | 功能补齐 |
| 6 | 端到端验收(新剧本 + 历史剧本) | 验收 |
## 6. 影响范围
| 文件 | 改动量 |
|---|---|
| `server/.../EpicScriptServiceImpl.java` | 小(getScriptById 检查 + 自动建 conversation |
| `mini-program/src/components/MessageCard.vue` | 中(+props、+emits、+按钮组条件渲染) |
| `mini-program/src/pages/main/ScriptView.vue` | 中(displayMessages filter、MessageCard props、formatVersionLabel、viewMessageVersions、loadMessages 初始化) |
## 验收标准(代码层面)
- [ ] chat 模式下,根版本 AI 剧本消息显示为 chat 列表首条
- [ ] AI 剧本消息显示 V1 / V2 版本号 + (当前) 标记
- [ ] AI 剧本消息按钮组包含:收起/展开、复制、TTS 播放、改写、续写、查看历史版本、删除版本
- [ ] 用户消息(type=chat)保持气泡样式,有收起/展开按钮
- [ ] 点击「改写」弹出 modal 输入改写意图,提交后生成新版本
- [ ] 点击「续写」直接生成新版本
- [ ] 点击「查看历史版本」弹 ActionSheet 列出版本,切换后 UI 更新
- [ ] 当前生效版本不显示「删除版本」按钮
- [ ] 历史剧本进入 ScriptView chat 模式 → 自动创建 conversation + 初始化首条 AI 消息
- [ ] 浏览器 Console 无报错
- [ ] 不影响 read 模式(阅读模式保持现状)
## H5 端到端验收流程(强制)
**后端验证码**`AuthServiceImpl.java:80` 已硬编码 `DEFAULT_SMS_CODE = "123456"`,无需改动。
**测试账号**:手机号 `19928748688`,验证码 `123456`
### 步骤 1:启动本地 H5
```bash
python dev-services.py start mini-program
# 访问 http://localhost:5180
```
### 步骤 2:登录
1. 打开 `http://localhost:5180`
2. 输入手机号 `19928748688`
3. **必须点击"发送验证码"按钮**(触发 `/auth/sms-code` 接口,后端写入 Redis 并返回固定验证码)
4. 输入验证码 `123456`
5. 提交登录
### 步骤 3:新剧本完整流程
1. 首页输入心愿 → 生成第一个剧本
2. 查看剧本详情 → 标题、summary、content 完整显示
3. 点击"继续" → 进入 ScriptView chat 模式
4. **验证**
- 用户心愿气泡显示
- AI 剧本消息显示,带 `V1 (当前)` 版本标签
- AI 消息按钮组包含:收起/展开、复制、TTS 播放、**改写**、**续写**、**查看历史版本**、**删除版本**(当前版本不显示删除)
5. 点"改写" → 输入改写意图 → 生成新版本 V2
6. 点"查看历史版本" → ActionSheet 列出 V1/V2,点击切换
7. 点"续写" → 生成新内容
### 步骤 4:历史剧本完整流程
1. 点"历史"按钮 → 进入剧本库
2. 点任一**历史剧本**(非本次新生成的)→ 进入 ScriptDetailView
3. **验证**
- 标题、summary、content 完整显示(读 `plotJson.fullContent`
- 风格、篇幅、字数正确
4. 点"继续" → 进入 ScriptView
5. **验证**
- 自动创建 `t_conversation`DB 中新增记录)
- 自动初始化首条 AI 消息(基于 plotJson
- chat 模式看到完整对话历史(心愿 + AI 剧本)
- AI 消息带版本标签
- 改写/续写/查看历史版本 可正常操作
### 步骤 5Console 与 Network 检查
- DevTools Console 无报错
- Network 中所有 API 调用返回 2xx(无 5xx、无 401、无 403
- `listByConversation` 调用返回 200 且有数据
### 步骤 6:验收通过标准
- ✅ 新剧本 + 历史剧本所有功能正常
- ✅ 按钮组完整
- ✅ 版本标签正确
- ✅ Console 无报错
- ✅ 历史剧本 conversation 自动创建成功
**未通过的处理**:任一验收项失败 → 停止 → 定位问题 → 修复 → 重新验收 → 直到全部通过才算完成
## 风险与回退
- **MessageCard 重构风险**:新增 props 都带默认值,不会破坏非对话模式(生成页、结果页)的现有用法
- **向后兼容**:旧 `continue` emit 保留,新增 `continue-script` 作为 script 专属,避免语义冲突
- **回退方案**git revert 单个 commit 即可
@@ -0,0 +1,221 @@
---
author: Peanut
created_at: 2026-06-29
purpose: ScriptView 简化为纯对话流模式:删除 read/chat 二段切换 + 按旧版布局统一 AI 消息按钮组
---
# ScriptView 简化为纯对话流模式
## 背景
`bf6f279 feat: ScriptView 改造为对话中心查看/修改模式` 引入了 read/chat 二段切换,但偏离了原始设计。用户的原始设计意图:
- **从剧本库点卡片进入**:直接以对话形式查看完整的从创建剧本开始的对话(用户心愿 → AI 剧本 → 改写对话 → 续写对话 → ...)
- **AI 消息卡片**:按旧版布局展示按钮组
- **不要二段切换**:不要先 read 再点按钮进 chat
## 旧版布局参考(来自 commit `cbf5157` 和 `0db434c`
```
MessageCard 顶部(story-head-actions):
┌──────────────────────────────────┐
│ [剧本标题] [收起/展开] [复制] │
└──────────────────────────────────┘
MessageCard 底部(result-actions):
┌──────────────────────────────────┐
│ [换个方向] [不像我] [继续生成] [▶ 播放] │
└──────────────────────────────────┘
```
## 设计
### 1. 入口流程简化
| 入口 | 改动 |
|---|---|
| 剧本库点卡片 | `openScriptChat` → 直接设 `viewState='result'``viewMode='chat'` |
| 首页心愿输入生成新剧本 | 保持现状(home → generating → result |
| read 模式 | **整个删除** |
### 2. 删除 read 模式相关代码
**删除的位置**
| 文件 | 位置 | 内容 |
|---|---|---|
| `mini-program/src/pages/main/ScriptView.vue` | template `view v-if="viewMode === 'read'"` | 整个版本切换器 + 章节展示 + read-action-bar |
| `mini-program/src/pages/main/ScriptView.vue` | `selectedVersionMessageId` ref | 只被 read 模式用 |
| `mini-program/src/pages/main/ScriptView.vue` | `onSelectVersion` | 版本切换(read 模式用) |
| `mini-program/src/pages/main/ScriptView.vue` | `setAsCurrentVersion` | 设为当前版本(read 模式用) |
| `mini-program/src/pages/main/ScriptView.vue` | `enterChatMode` | read → chat 切换(不再需要) |
| `mini-program/src/pages/main/ScriptView.vue` | `rewriteCurrentVersion` | read 模式改写入口 |
| `mini-program/src/pages/main/ScriptView.vue` | `continueCurrentVersion` | read 模式续写入口 |
`currentVersionMessageId` 保留:chat 模式下 AI 消息的"删除版本"按钮需要它来判断 `canDelete`
### 3. 按旧版布局统一 AI 消息按钮组
**MessageCard 改造**
```vue
<template>
<!-- 短消息气泡保持现状 -->
<view v-if="isShortMessage && messageType !== 'script'" class="chat-bubble system">
<text>{{ content }}</text>
</view>
<!-- 长文本卡片story-card -->
<view v-else class="story-card" :class="{ collapsed }">
<!-- 顶部 story-head-actions收起/展开 + 复制 + 版本号 -->
<view class="story-head">
<view class="story-title-wrap">
<text v-if="versionLabel" class="version-label">{{ versionLabel }}</text>
<text class="story-title">{{ title || '我的人生剧本' }}</text>
<view v-if="tags?.length" class="tag-row">
<text v-for="tag in tags" :key="tag" class="tag">{{ tag }}</text>
</view>
</view>
<view class="story-head-actions">
<button class="collapse-icon" @click="$emit('toggle-collapse')">
<text class="collapse-icon-text">{{ collapsed ? '展开' : '收起' }}</text>
<view class="collapse-chevron" :class="{ down: collapsed }">
<view></view><view></view>
</view>
</button>
<button class="copy-card-btn" @click="$emit('copy')">复制</button>
</view>
</view>
<!-- 正文 -->
<scroll-view v-if="collapsed" class="story-body-scroll" scroll-y>
<text class="story-body" :selectable="true" :user-select="true">{{ content }}</text>
</scroll-view>
<text v-else class="story-body" :selectable="true" :user-select="true">{{ content }}</text>
<!-- 底部收起/展开行 -->
<view class="collapse-row" @click="$emit('toggle-collapse')">
<text class="collapse-row-text">{{ collapsed ? '展开全文' : '收起全文' }}</text>
<view class="collapse-chevron" :class="{ down: collapsed }">
<view></view><view></view>
</view>
</view>
<!-- 底部 result-actions按旧版布局 + 加上 script 专属按钮 -->
<view class="result-actions">
<!-- 旧版基础按钮 -->
<button class="action-btn" @click="$emit('change-direction')">换个方向</button>
<button class="action-btn" @click="$emit('not-like-me')">不像我</button>
<button v-if="canContinue" class="action-btn" @click="$emit('continue-script')">续写</button>
<!-- script 专属功能按钮 -->
<button v-if="canRewrite" class="action-btn" @click="$emit('rewrite')">改写</button>
<button v-if="hasChildren" class="action-btn" @click="$emit('view-versions')">查看历史版本</button>
<button v-if="canDelete" class="action-btn danger" @click="$emit('delete-version')">删除版本</button>
<!-- TTS 播放 -->
<button class="action-btn primary" @click="$emit('play-tts')">
<text class="action-icon">{{ ttsIcon || '▶' }}</text>
<text>{{ ttsText || '播放' }}</text>
</button>
</view>
</view>
</template>
```
**关键变化**
- `messageType === 'script'` 分支去掉(长文本统一渲染)
- `result-actions` 在 script 类型下按旧版布局 + 加 script 专属按钮
- 短消息气泡(chat/system)保持不变
### 4. ScriptView template 简化
**简化前的 read 模式 section**
```vue
<view v-if="viewMode === 'read'">
<view class="version-bar">...</view>
<view class="script-read-content">...</view>
<view class="read-action-bar">...</view>
</view>
```
**删除**
**简化后的 chat 模式 section**
```vue
<view class="result-chat-view">
<view class="conversation compact">
<!-- 用户心愿气泡 -->
<view class="chat-bubble user">
<text>{{ wishText }}</text>
<text class="bubble-time">{{ currentMessageTime }}</text>
</view>
<!-- 系统提示 -->
<view class="chat-bubble system done">
<text>心愿已实现故事已为你展开</text>
<text class="bubble-time">{{ currentResultTime }}</text>
</view>
</view>
<!-- 对话消息列表 -->
<view v-if="displayMessages.length" class="result-chat-list">
<view v-for="message in displayMessages" :key="message.id">
<MessageCard
v-if="message.sender === 'assistant' || message.type === 'script'"
message-type="script"
:version-label="formatVersionLabel(message)"
:has-children="messageHasChildren(message)"
:can-delete="message.id !== currentVersionMessageId && message.parentMessageId !== null"
:can-rewrite="true"
:can-continue="true"
:content="message.content"
:collapsed="isMessageCollapsed(message)"
:content-length="message.content.length"
:is-short-message="false"
:tts-icon="..."
:tts-text="..."
@toggle-collapse="..."
@copy="..."
@rewrite="..."
@continue-script="..."
@view-versions="..."
@delete-version="..."
@play-tts="..."
@change-direction="..."
@not-like-me="..."
/>
<view v-else class="chat-bubble user">
<text>{{ message.content }}</text>
<text class="bubble-time">{{ message.time || formatMessageTime() }}</text>
</view>
</view>
</view>
<!-- 输入栏 -->
<view class="result-chat-bar">...</view>
</view>
```
### 5. ScriptView 的 `continue` / `change-direction` / `not-like-me` emits
旧版 emit 在 script-actions 下不存在,需要保留以兼容旧版按钮。
- `continue`(旧版 "继续生成"按钮)→ 触发 `continueMessage``continueInChat`
- `change-direction`"换个方向")→ 触发 `changeDirection`
- `not-like-me`"不像我")→ 触发 `notLikeMe`
**这些函数已经存在于 ScriptView.vue 中**(来自旧版代码)。直接绑定。
## 验收标准
- [ ] 从剧本库点卡片 → 直接进入对话流(无需点"进入对话修改")
- [ ] 对话流完整显示:用户心愿 + AI 剧本 + 改写/续写对话
- [ ] AI 消息卡片顶部:收起/展开、复制、版本号标签
- [ ] AI 消息卡片底部:换个方向、不像我、续写、改写、查看历史版本、删除版本、TTS 播放
- [ ] read 模式相关代码完全删除(不再有任何切换按钮)
- [ ] Console 无报错
## 风险与回退
- **风险低**:只删除 read 模式 + 调整 MessageCard 按钮布局,不影响后端
- **回退**`git revert` 单个 commit 即可恢复
@@ -0,0 +1,67 @@
---
author: AI Assistant
created_at: 2026-07-19
purpose: 修复 chat 视图底部发送按钮过高过大、与输入框不等高的问题
---
# Chat 视图发送按钮等高修复设计
## 概述
chat 视图底部 `.chat-input-bar` 中"发送"按钮(`.chat-send-btn`)的 `padding: 16rpx 32rpx` 撑大了按钮高度,与文本输入框 `.result-chat-input``min-height: 60rpx`)不等高,视觉上按钮比输入框大。
## 问题
- `.chat-send-btn { padding: 16rpx 32rpx; }` — 上下各 16rpx padding 撑大按钮
- `.result-chat-input { min-height: 60rpx; }` — 输入框有 min-height,但实际渲染高度随内容变化
- `.chat-input-bar { align-items: flex-end; }` — 底部对齐进一步放大不等高视觉差异
## 修复方案
### 改动文件
- `mini-program/src/pages/main/ScriptView.vue`(样式部分)
### 改动 1`.chat-send-btn` 改 padding + 设 height
```css
.chat-send-btn {
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 32rpx;
background: #087e8b;
border-radius: 32rpx;
color: #ffffff;
font-size: 28rpx;
}
```
**关键变化**
- 新增 `height: 60rpx`:与 `.result-chat-input``min-height: 60rpx` 保持一致
- `padding: 16rpx 32rpx``padding: 0 32rpx`:去掉上下 padding,靠 height+flex 居中文字
- 新增 `display: flex; align-items: center; justify-content: center;`:文字垂直居中
### 改动 2`.chat-input-bar` 改 `align-items`
```css
.chat-input-bar {
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx 24rpx;
background: rgba(255, 255, 255, 0.04);
border-top: 2rpx solid rgba(255, 255, 255, 0.1);
}
```
**关键变化**
- `align-items: flex-end``align-items: center`:按钮和输入框垂直居中对齐
## 验收标准
- [ ] 发送按钮与文本输入框等高
- [ ] 按钮文字垂直居中
- [ ] mp-weixin 编译通过
- [ ] H5 浏览器 Console 无报错
@@ -0,0 +1,94 @@
---
author: AI Assistant
created_at: 2026-07-19
purpose: 修复 ScriptView chat 视图底部 outline 输入框和按钮被主页 bottom-nav 遮挡的问题(架构层修正)
---
# Chat 视图底部遮挡架构层修复设计
## 概述
前一轮 CSS 修复(`padding-bottom: 120rpx`)未能彻底解决 bottom-nav 遮挡问题:`.outline-feedback` 输入框的 placeholder "如需修改请输入意见" 被截断,"修改大纲" 和 "确认大纲" 按钮也无法完整可见。根因是 `.chat-page` 使用 `height: 100vh`,完全无视父级 `.content` 已有的 `padding-bottom: 132rpx`(已经为 bottom-nav 预留了空间)。
## 问题根因
主页面 `pages/main/index.vue` 结构:
```css
.app-shell { height: 100vh; display: flex; flex-direction: column; }
.content { flex: 1; height: 0; padding: 0 28rpx 132rpx; } /* 父级已经预留 132rpx */
.bottom-nav { position: absolute; bottom: 0; height: 104rpx; z-index: 20; }
```
ScriptView 组件被渲染在 `.content` 内。父级 `.content` 已经预留了 132rpx 底部空间给 bottom-nav,但 `.chat-page` 设置了 `height: 100vh`,这让它撑满整个视口高度,**无视了父级的 padding-bottom**,导致 chat 滚动区底部仍然被 bottom-nav 覆盖约 104-134rpx。
## 修复方案
`.chat-page` 填充父级内容区(不设固定视口高度),让父级已有的 132rpx padding-bottom 自然生效。
### 改动文件
- `mini-program/src/pages/main/ScriptView.vue`(样式部分)
### 改动 1`.chat-page` 从 `height: 100vh` 改为 `height: 100%`
```css
.chat-page {
/* height: 100vh → height: 100% */
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: #13091f;
box-sizing: border-box;
padding-bottom: env(safe-area-inset-bottom);
}
```
**关键变化**
- `height: 100%`:填充父级 `.content` 的可用高度,父级的 132rpx padding-bottom 自然生效
- 保留 `min-height: 0`:让 flex 子项可以正确收缩
- 保留 `padding-bottom: env(safe-area-inset-bottom)`:适配 iPhone home indicator
- 删除 `constant(safe-area-inset-bottom)`iOS 11.0-11.2 已极少,无需兼容
### 改动 2`.chat-scroll-content` 改回 `padding: 0 24rpx 40rpx`
```css
.chat-scroll-content {
padding: 0 24rpx 40rpx;
}
```
**为什么改回 40rpx**
- 父级 `.content` 已经预留 132rpx 给 bottom-nav
- chat-page 用 `height: 100%` 后,滚动区底部已经被父级 padding 隔开
- 不再需要额外的 120rpx padding(否则会双重叠加变成 252rpx 空白)
## 为什么这样能解决问题
1. 父级 `.content` 的 132rpx padding-bottom 创建了一个 "安全区"
2. chat-page 用 `height: 100%` 填充父级内容可用高度(不含 132rpx 安全区)
3. chat-scrollflex: 1 + height: 100%)继承这个受限高度
4. chat-scroll-content 在受限区域内滚动,底部自动避开 bottom-nav
5. outline 输入框和按钮都在 chat-scroll-content 内,自然在安全区内
6. placeholder "如需修改请输入意见" 完整显示,按钮可点击
## 不影响范围
- home 视图(`.wish-home`):独立样式,不受影响
- generation 视图(`.generation-view`):独立样式,不受影响
- 其他 message 类型(user bubble、novel、text、card-answered):样式不变
- chat-input-bar:条件渲染在 scroll-view 外,不受影响
- mp-weixin 滚动:`.chat-scroll` 仍保持 `flex: 1; height: 100%; min-height: 0`,原生 scroll-view 仍能正确计算可滚动区域
## 验收标准
- [ ] H5http://localhost:5180)心愿实现 chat 视图:outline 输入框的 placeholder "如需修改请输入意见" 完整可见(不被截断)
- [ ] "修改大纲" 和 "确认大纲" 按钮在 bottom-nav 上方完整可见可点击
- [ ] 向上滚动时大刚内容(标题、beats、结局)正常显示
- [ ] iPhone 真机(带 home indicator)底部也能完整看到所有内容
- [ ] Console 无报错
- [ ] mp-weixin 编译通过(`npm run build:mp-weixin` 无错误)
- [ ] 编译产物中 `.chat-page``height``100%``padding-bottom``env(safe-area-inset-bottom)`
- [ ] 编译产物中 `.chat-scroll-content``padding``0 24rpx 40rpx`
- [ ] home 视图、generation 视图、chat-input-bar 布局不受影响
@@ -0,0 +1,72 @@
---
author: AI Assistant
created_at: 2026-07-19
purpose: 修复 ScriptView chat 视图 ClarificationCard 提交按钮被主页 bottom-nav 遮挡的问题
---
# Chat 视图底部内边距修复设计
## 概述
小程序心愿实现页面(ScriptViewchat 视图的 ClarificationCard 提交按钮无法滚动到可见区域,根因是主页面 `pages/main/index.vue``.bottom-nav``position: absolute; bottom: 0; z-index: 20; height: 104rpx`)叠在 script-view 组件底部,遮挡了 chat 滚动内容的最后一段。
## 问题
用户在 chat 视图选择澄清卡片的选项后,需要点击卡片最下方的「提交」按钮继续流程,但:
- 滚动内容被 bottom-nav 遮住约 104rpx
- iPhone 真机底部 home indicator 进一步压缩可见区域
- 用户能看到卡片上半部分(问题描述、选项、自定义输入框)但看不到「提交」按钮
- 无法继续后续流程
## 修复方案
调整 chat 视图容器的底部内边距,让滚动内容可以滚到「提交」按钮完全可见在 bottom-nav 上方。
### 改动文件
- `mini-program/src/pages/main/ScriptView.vue`(样式部分)
### 改动 1`.chat-page` 增加 iPhone 安全区内边距
```css
.chat-page {
height: 100vh;
min-height: 0;
display: flex;
flex-direction: column;
background: #13091f;
box-sizing: border-box;
padding-bottom: env(safe-area-inset-bottom);
}
```
作用:让 chat 容器适配 iPhone 底部 home indicator 区域,避免聊天内容被 home indicator 遮挡。
### 改动 2`.chat-scroll-content` 增加底部内边距
```css
.chat-scroll-content {
padding: 0 24rpx 120rpx;
}
```
作用:在 chat 滚动区底部留出 120rpx 空间:
- 104rpx = bottom-nav 高度
- 16rpx = 呼吸间距,避免按钮贴边
保证 ClarificationCard 的「提交」按钮可滚动到 bottom-nav 上方完整可见。
## 不影响范围
- home 视图:使用独立的样式(`.wish-home`),不受影响
- chat-input-bar:条件渲染且在 scroll-view 外部,不受影响
- 其他 message 类型(user bubble、outline、novel、error):样式结构不变
## 验收标准
- [ ] 在 H5http://localhost:5180)心愿实现 chat 视图,澄清卡片可滚动到「提交」按钮完全可见
- [ ] 「提交」按钮在主页 bottom-nav 导航栏上方,无遮挡
- [ ] iPhone 真机(带 home indicator)底部也能完整看到「提交」按钮
- [ ] Console 无报错
- [ ] mp-weixin 编译通过(`npm run build:mp-weixin` 无错误)
- [ ] 点击「提交」后流程正常继续(追加 user 消息 + 调 followupStream
@@ -0,0 +1,109 @@
---
author: AI Assistant
created_at: 2026-07-19
purpose: 修复心愿实现 chat 视图在等待接口响应期间没有 loading 标识、呈现空白等待状态的问题
---
# 心愿实现 chat 视图 Loading 标识修复设计
## 概述
心愿实现页面(`ScriptView.vue` 的 chat 视图)在用户发送消息后、等待后端 SSE 接口响应的阶段,页面处于空白状态,没有任何 loading 标识,无法判断是否卡住。需要在等待期间展示动态 loading,提升等待体验。
## 问题根因
`ScriptView.vue` 中 chat-loading 组件的显示条件(line 177):
```html
<view v-if="generating && generationStatus !== 'failed'" class="chat-loading">
```
`runGeneration` 函数(line 1618-1672)中:
```js
generating.value = true // Line 1630
// ...
try {
currentStreamTask.value = startNovelStream({...}) // Line 1653 - 异步 fire-and-forget
} finally {
generating.value = false // Line 1671 - 立即执行!
}
```
**Bug**`startNovelStream` 是异步流式调用(fire-and-forget,没有 await),`finally` 在流启动后**立即**把 `generating` 设回 `false`。结果:
1. 用户点发送 → `generating = true` → 切换到 chat 视图
2. 几乎同时 → `finally` 执行 → `generating = false` → chat-loading 立即隐藏
3. 在第一个 SSE 事件(`status`)到达前,页面只有用户消息气泡,下方空白
4. 用户看到"发送 → 空白等待 → 突然跳出内容",无法判断系统是否在工作
## 修复方案
**用新的 computed `showChatLoading` 替换原有显示条件**,语义精确匹配"等待首个 assistant 响应"
```js
const showChatLoading = computed(() => {
if (generationPhase.value !== 'generating') return false
if (generationStatus.value === 'failed') return false
const lastMsg = resultMessages.value[resultMessages.value.length - 1]
// 最后一条消息是 user(刚发送的请求),说明还没收到 assistant 响应
return lastMsg && lastMsg.role === 'user'
})
```
```html
<view v-if="showChatLoading" class="chat-loading">
<view class="loading-orbit" :class="{ streaming: generationStatus === 'streaming' }">
<view class="orbit-ring outer"></view>
<view class="orbit-ring inner"></view>
<view class="orbit-core"></view>
</view>
<text class="loading-copy">{{ generationCopy }}</text>
</view>
```
### 场景覆盖表
| 场景 | generationPhase | 最后一条消息 | loading 显示 |
|---|---|---|---|
| 用户刚发送 | 'generating' | user | ✅ 显示 |
| SSE status 事件(processing 阶段)| 'generating' | user | ✅ 显示 |
| `clarification_card` 到达 | 'generating' | assistant (card) | ❌ 隐藏 |
| 用户回答澄清问题 | 'generating' | user | ✅ 显示 |
| `outline_created` 到达 | 'generating' | assistant (outline) | ❌ 隐藏 |
| 用户提交大纲修改意见 | 'generating' | user | ✅ 显示 |
| `novel_start` 到达 | 'generating' | assistant (novel) | ❌ 隐藏 |
| `novel_done` 到达 | 'done' | assistant | ❌ 隐藏 |
### 为什么这样设计
1. **用 `generationPhase` 而非 `generating`**`generationPhase``runGeneration` 开始时设为 'generating',在 `novel_done` 时设为 'done',完整覆盖生成流程生命周期。`generating` 因为 try-finally 立即重置,不可靠。
2. **用最后一条消息的 role 判断**assistant 消息(card/outline/novel)到达后,用户看到的是交互内容而非空白,loading 应自动隐藏。通过判断最后一条消息的 role,精确实现"等待首个响应"的语义。
3. **支持多轮交互**:用户回答澄清问题、提交大纲修改后,最后一条又变回 user,loading 自动重新显示。无需额外的 flag 管理。
## 改动文件
- `mini-program/src/pages/main/ScriptView.vue`
- 新增 computed `showChatLoading`(在 `generationCopy` 附近,约 line 930-950
- 替换 line 177 的 `v-if` 条件
## 不影响范围
- `chat-loading` 组件的样式、动画内容保持不变
- `generating` 变量的其他用途(按钮禁用、重试逻辑等)不变
- `generationStatus``generationPhase``generationCopy` 等 computed 不变
- `home` 视图、`generation` 视图、`chat-input-bar` 不受影响
- `sendChat``sendRewrite``sendChatAsContinue` 等其他流程不受影响(它们本身有 await,generating 状态正确)
## 验收标准
- [ ] H5`http://localhost:5180`):点击发送后,chat 视图立即显示 loading 动画(loading-orbit + generationCopy 文本)
- [ ] Loading 持续到 `clarification_card` / `outline_created` / `novel_start` 任一事件到达前
- [ ] 澄清卡片 / 大纲 / 小说消息出现后,loading 自动隐藏
- [ ] 用户回答澄清、提交大纲修改后,loading 重新显示直到下一个 assistant 响应到达
- [ ] 失败状态(`generationStatus === 'failed'`)不显示 loading,显示 resume 按钮
- [ ] 浏览器 Console 无新增错误
- [ ] mp-weixin 编译通过(`npm run build:mp-weixin` 无 error
- [ ] 编译产物中 `.chat-loading` 的 v-if 使用 `showChatLoading`
@@ -0,0 +1,100 @@
---
author: AI Assistant
created_at: 2026-07-19
purpose: 修复澄清卡片已回答显示英文 value 而非中文 label,以及已回答状态不显示 AI 问题的问题
---
# 澄清卡片已回答显示修复设计
## 概述
ScriptView chat 视图中,澄清卡片(ClarificationCard)提交后:
1. **Bug 1**"已回答:competition" 显示的是英文 value,而非中文 label"工作/学习上的事"
2. **Bug 2**:已回答状态不显示 AI 的问题(如"你想重写今天的什么经历?")
根因:ClarificationCard 的 `handleSubmit` 发出的是选项的 value(如 'competition'),`submitClarification` 直接用 value 作为显示文本和后端 payload。
## 修复方案
### 改动文件
- `mini-program/src/pages/main/ScriptView.vue`
### 改动 1submitClarification 用 label 显示,value 发给后端
```js
const submitClarification = (answer) => {
if (answeringClarification.value) return
const card = [...resultMessages.value].reverse().find(m => m.kind === 'card' && !m.submitted)
if (card) {
// 通过 value 查找选项的 label
const option = card.card?.options?.find(opt => opt.value === answer)
const label = option?.label || answer
card.submitted = true
card.answer = label // 显示用 label(中文)
}
// 用户气泡显示 label
const displayAnswer = card?.card?.options?.find(opt => opt.value === answer)?.label || answer
addResultMessage({ role: 'user', kind: 'text', content: displayAnswer })
answeringClarification.value = true
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'answer_clarification',
payload: { answer }, // 后端仍收 value(语义化)
onEvent: handleShortNovelEvent,
onError: (errMsg) => markGenerationFailed(errMsg)
})
setTimeout(() => { answeringClarification.value = false }, 200)
}
```
### 改动 2card-answered 显示 AI 的问题
```html
<view v-else class="card-answered">
<text class="card-question-text">{{ msg.card?.question || '' }}</text>
<text>已回答:{{ msg.answer }}</text>
</view>
```
新增 `.card-question-text` 样式(复用 ClarificationCard 的 `.card-question` 样式):
```css
.card-question-text {
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
margin-bottom: 16rpx;
line-height: 1.5;
display: block;
}
```
## 数据流
```
用户点选"工作/学习上的事"value: 'competition'
└─ ClarificationCard.handleSubmit
└─ emit('submit', 'competition')
└─ ScriptView.submitClarification('competition')
├─ 查找 option → label = '工作/学习上的事'
├─ card.answer = '工作/学习上的事'(显示)
├─ addResultMessage({ content: '工作/学习上的事' })
└─ followupStream({ payload: { answer: 'competition' } })(后端)
```
## 不影响范围
- ClarificationCard.vue 组件本身不修改(保持 value 的 emit 行为)
- 后端收到的 answer 仍是 value(语义化,便于判断选择)
- outline、novel、text 等其他消息类型不受影响
- mp-weixin 模板限制:`v-if` 不能用箭头函数,`submitClarification` 中查找 option 的逻辑在 JS 层完成
## 验收标准
- [ ] 澄清卡片提交后,"已回答:"后面显示中文 label(如"工作/学习上的事"
- [ ] 用户气泡显示中文 label(如"工作/学习上的事"
- [ ] 已回答状态显示 AI 的问题(如"你想重写今天的什么经历?")
- [ ] 后端收到的 answer 仍是 value(如 'competition'
- [ ] 浏览器 Console 无新增错误
- [ ] mp-weixin 编译通过
@@ -0,0 +1,270 @@
---
author: AI Assistant
created_at: 2026-07-19
purpose: 小程序收藏功能真实 API 化,沿用现有独立收藏表架构
---
# 小程序收藏功能真实 API 化设计(v2)
## 概述
当前小程序的「收藏」功能(剧本收藏、人生事件收藏)完全基于前端 localStorage,未对接后端 API。
**重要发现**:剧本收藏后端 API 已经完整实现(独立的 `t_script_favorite` 表 + `ScriptFavoriteService` + Controller),但前端 `ScriptLibraryView.vue` 没有使用这些 API,还在用 localStorage。
本次修复:
1. **剧本收藏**:前端从 localStorage 切换到现有 API
2. **人生事件收藏**:仿照剧本收藏,新建独立的收藏表和 API,删除假接口
## 现状问题
### 剧本收藏
| 位置 | 问题 |
|---|---|
| `ScriptLibraryView.vue:159` | `localFavorites = ref(uni.getStorageSync('script_favorites') \|\| {})` |
| `ScriptLibraryView.vue:319-369` | `toggleFavorite` / `deleteScriptFromFavorites` 只写 localStorage |
| `ScriptLibraryView.vue:244` | `isFavorite``script.isFavorite \|\| localFavorites.value[id]` 读取 |
| `services/epicScript.js:206-232` | ✅ 已有 `toggleFavoriteScript` / `checkFavoriteScript` API 封装,但前端未调用 |
**后端已实现**
- `POST /epicScript/favorite/toggle` — 切换收藏
- `GET /epicScript/favorite/check?scriptId=xxx` — 检查单个剧本收藏状态
- `GET /epicScript/favorite/page` — 分页查询收藏列表
- `ScriptFavoriteService.getFavoritedScriptIds(Set<String>)` — 批量检查(Controller 未暴露)
### 人生事件收藏
| 位置 | 问题 |
|---|---|
| `life-event/detail.vue:144` | `isFavorite``event_favorite_${id}` localStorage 读 |
| `life-event/detail.vue:315-316` | toggle 写/删 `event_favorite_${id}` localStorage |
| `services/lifeEvent.js:67-69` | `favoriteEvent` 调用 `/lifeEvent/favorite-placeholder` 假接口 |
| `LifeEventController.java:153-163` | `/lifeEvent/favorite-placeholder` 是空壳,返回 echo + `placeholder: true` |
## 设计方案
### 存储方案(沿用现有架构)
**剧本收藏**:沿用现有 `t_script_favorite` 独立表(无需改动)。
**人生事件收藏**:仿照剧本,新建独立的 `t_event_favorite` 表。
```sql
CREATE TABLE t_event_favorite (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
event_id VARCHAR(64) NOT NULL COMMENT '事件ID',
create_by VARCHAR(64) COMMENT '创建人',
create_time DATETIME COMMENT '创建时间',
update_by VARCHAR(64) COMMENT '更新人',
update_time DATETIME COMMENT '更新时间',
is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除:0-否 1-是',
remarks VARCHAR(500) COMMENT '备注',
INDEX idx_user_event (user_id, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人生事件收藏关联表';
```
**选择独立表的理由**
1. 与剧本收藏架构保持一致,两套收藏机制对称
2. 支持多用户场景(每个用户独立收藏)
3. 支持分页查询"我的收藏"列表
4. 逻辑删除保留历史记录
### 后端改动
#### 1. 人生事件收藏(新增)
**Entity**`EventFavorite.java`
```java
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_event_favorite")
public class EventFavorite extends BaseEntity {
@TableField("user_id")
private String userId;
@TableField("event_id")
private String eventId;
}
```
**Mapper**`EventFavoriteMapper.java`
```java
@Mapper
public interface EventFavoriteMapper extends BaseMapper<EventFavorite> {
}
```
**Service**`EventFavoriteService.java` + `EventFavoriteServiceImpl.java`
```java
public interface EventFavoriteService extends IService<EventFavorite> {
EventFavoriteResponse toggleFavorite(String eventId);
PageResult<LifeEventResponse> getFavoritePage(long current, long size);
Set<String> getFavoritedEventIds(Set<String> eventIds);
boolean isFavorited(String eventId);
long getFavoriteCount();
}
```
实现完全仿照 `ScriptFavoriteServiceImpl`
**DTO**
- `EventFavoriteToggleRequest.java``{ eventId: String }`
- `EventFavoriteResponse.java``{ eventId, isFavorited, favoriteTime }`
**Controller**`LifeEventController.java` 新增
```java
// ==================== 人生事件收藏 ====================
@PostMapping(value = "/favorite/toggle")
public Result<EventFavoriteResponse> toggleFavorite(
@Valid @RequestBody EventFavoriteToggleRequest request) {
return Result.success(eventFavoriteService.toggleFavorite(request.getEventId()));
}
@GetMapping(value = "/favorite/page")
public Result<PageResult<LifeEventResponse>> getFavoritePage(
@RequestParam(defaultValue = "1") long current,
@RequestParam(defaultValue = "10") long size) {
return Result.success(eventFavoriteService.getFavoritePage(current, size));
}
@GetMapping(value = "/favorite/check")
public Result<EventFavoriteResponse> checkFavorite(@RequestParam String eventId) {
boolean favorited = eventFavoriteService.isFavorited(eventId);
EventFavoriteResponse r = new EventFavoriteResponse();
r.setEventId(eventId);
r.setIsFavorited(favorited);
return Result.success(r);
}
```
**删除**`/lifeEvent/favorite-placeholder` 假接口
#### 2. 剧本收藏(无需改动)
后端已完整,无需新增。
### 前端改动
#### 1. `ScriptLibraryView.vue`
**删除**
- `const localFavorites = ref(uni.getStorageSync('script_favorites') || {})`
- `toggleFavorite` / `deleteScriptFromFavorites` 的 localStorage 写/删逻辑
**修改**
- `isFavorite` 计算改为:调用 `epicScriptService.checkFavoriteScript(script.id)` 或在列表加载后批量检查
- `toggleFavorite` 改为调 `epicScriptService.toggleFavoriteScript(script.id)`
**优化**:为避免 N+1 请求,可在后端新增 `GET /epicScript/favorite/ids` 接口,返回当前用户所有收藏的 scriptId 集合,前端列表加载后一次性拿到。但本次先用 `checkFavoriteScript` 逐个检查,后续优化。
#### 2. `services/lifeEvent.js`
**修改**
```js
// 之前:post('/lifeEvent/favorite-placeholder', { id, favorite })
// 之后:
export const toggleFavoriteEvent = async (eventId) => {
const res = await post('/lifeEvent/favorite/toggle', { eventId })
return {
success: true,
isFavorited: res.data?.isFavorited ?? false,
favoriteTime: res.data?.favoriteTime ?? null
}
}
export const checkFavoriteEvent = async (eventId) => {
const res = await get('/lifeEvent/favorite/check', { eventId })
return res.data?.isFavorited ?? false
}
```
**删除**:旧的 `favoriteEvent` 方法
#### 3. `life-event/detail.vue`
**删除**
- `const isFavorite = ref(Boolean(uni.getStorageSync('event_favorite_${id}')))`
- `toggleFavorite` 的 localStorage 写/删
**修改**
- `isFavorite` 改为 `ref(false)`,页面加载时调用 `checkFavoriteEvent(id)` 初始化
- `toggleFavorite` 改为调 `toggleFavoriteEvent(id)`
## 数据流
```
剧本列表页 ScriptLibraryView
└─ getScriptList() (GET /epicScript/listAll)
└─ 返回剧本列表
└─ 对每个剧本调用 checkFavoriteScript(id) (GET /epicScript/favorite/check)
└─ 返回 isFavorited
└─ 前端渲染收藏状态
用户点剧本收藏
└─ toggleFavoriteScript(id) (POST /epicScript/favorite/toggle)
└─ 后端 t_script_favorite 表 insert/delete
└─ 返回 isFavorited
└─ 前端更新 UI
人生事件详情页 life-event/detail
└─ getEventById(id) (GET /lifeEvent/detail)
└─ 返回事件详情
└─ checkFavoriteEvent(id) (GET /lifeEvent/favorite/check)
└─ 返回 isFavorited
└─ 前端渲染收藏状态
用户点事件收藏
└─ toggleFavoriteEvent(id) (POST /lifeEvent/favorite/toggle)
└─ 后端 t_event_favorite 表 insert/delete
└─ 返回 isFavorited
└─ 前端更新 UI
```
## 不影响范围
- 列表渲染、筛选、排序逻辑不变,只换数据源(localStorage → API
- 详情页展示逻辑不变(数据源变化在 sub-project B 清理)
- 其他页面的剧本/事件展示(如首页推荐)不受影响
## 数据库迁移
新增迁移 SQL 文件 `server/src/main/resources/db/migration/V20260719000002__create_event_favorite.sql`
```sql
CREATE TABLE t_event_favorite (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
event_id VARCHAR(64) NOT NULL COMMENT '事件ID',
create_by VARCHAR(64) COMMENT '创建人',
create_time DATETIME COMMENT '创建时间',
update_by VARCHAR(64) COMMENT '更新人',
update_time DATETIME COMMENT '更新时间',
is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除:0-否 1-是',
remarks VARCHAR(500) COMMENT '备注',
INDEX idx_user_event (user_id, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人生事件收藏关联表';
```
如果项目不用 Flyway,则手动执行或写入项目约定的 SQL 目录。
## 验收标准
- [ ] 剧本收藏:点击心形图标 → 调用 `POST /epicScript/favorite/toggle` → 后端字段更新 → 刷新页面后仍保持状态
- [ ] 人生事件收藏:点击收藏 → 调用 `POST /lifeEvent/favorite/toggle` → 同上
- [ ] 取消收藏:再次点击 → 同上
- [ ] 列表页 `isFavorite` 来自 API,不再依赖 localStorage
- [ ] 详情页 `isFavorite` 来自 API,不再依赖 localStorage
- [ ] `/lifeEvent/favorite-placeholder` 假接口已删除
- [ ] 浏览器 Console 无报错
- [ ] 后端 `mvn clean install` 编译通过
- [ ] 通过 `deploy.py backend` 部署到服务器后,在服务器环境验证
@@ -0,0 +1,249 @@
---
author: AI Assistant
created_at: 2026-07-19
purpose: ScriptView 心愿实现页面统一对话流改造设计,将澄清/大纲/小说生成全部纳入单一对话页面
---
# ScriptView 统一对话页改造设计
## 概述
将小程序心愿实现页面(ScriptView.vue)的生成流程从"多独立视图切换"改为"单一对话页面流"。用户从提交心愿开始,始终停留在同一个对话页面,澄清问答、大纲确认/修改、小说流式生成、后续对话修改都作为对话消息依次出现在聊天流里。
## 背景与问题
### 当前架构(AS-IS
ScriptView.vue 用 `viewState` 在 5 个独立视图间切换:
| viewState | 内容 | 对话页? |
|---|---|---|
| `home` | 心愿输入首页(灵感推荐+输入框) | 否 |
| `generating` | 生成中加载页(用户气泡+loading orbit | 否 |
| `clarifying` | 澄清卡片(只有 ClarificationCard | 否 |
| `outlining` | 大纲确认(大纲+确认/修改按钮) | 否 |
| `result` | 结果对话页(MessageCard+用户气泡+输入栏) | 是 |
问题:澄清、大纲、小说生成阶段用户"不在对话页面",体验割裂。用户要求"心愿实现时必须一直处于对话的页面,必须支持对话和选择的澄清判断"。
### 可复用资产
`result` 视图已有完整对话机制:
- `resultMessages` 数组 + `addResultMessage({id, role, kind, content, pending, time})`
- `MessageCard` 组件渲染 AI 消息
- 用户气泡渲染用户消息
- 底部输入栏 `sendChat`(当前接旧接口 `streamScriptChat`
- 滚动控制(`keepResultAtBottom``resultScrollTop` 等)
## 目标架构(TO-BE
### viewState 简化
从 5 个视图简化为 **2 个**
```
home(首页:灵感推荐+输入框,保留不变)
↓ 用户提交心愿
chat(统一对话页:澄清→大纲→小说→后续修改 全在这里)
```
删除独立的 `generating`/`clarifying`/`outlining`/`novel-generating` 视图。
### 统一对话消息模型
复用 `resultMessages` + `addResultMessage`,扩展 `kind` 字段:
```javascript
{
id, // createMessageId()
role, // 'user' | 'assistant'
kind, // 'text' | 'card' | 'outline' | 'novel'
content, // 文本内容(text/novel
pending, // 是否加载中(novel 流式时 true)
time, // formatMessageTime()
card?, // kind:'card' 时携带澄清卡片数据
outline?, // kind:'outline' 时携带大纲数据
submitted?, // kind:'card' 时标记是否已提交(禁用交互)
confirmed?, // kind:'outline' 时标记是否已确认/修改(禁用交互)
failed? // 是否失败(error 事件)
}
```
### 消息类型与渲染
`chat` 视图对话区用 `v-for` 遍历 `resultMessages`,根据 `kind` 渲染不同气泡内容:
| kind | role | 渲染方式 |
|---|---|---|
| `text` | user | 用户气泡(`chat-bubble user` |
| `text` | assistant | AI 文本气泡(`chat-bubble system`),含状态提示/错误 |
| `card` | assistant | AI 气泡内嵌 `ClarificationCard``submitted` 后禁用交互并显示已选答案 |
| `outline` | assistant | AI 气泡内嵌大纲卡片+确认/修改按钮,`confirmed` 后禁用按钮 |
| `novel` | assistant | AI 气泡内嵌小说正文,`pending` 时流式逐字追加,`pending=false` 后完成 |
新消息追加到底部,`keepResultAtBottom()` 自动滚动跟随。
### SSE 事件 → 对话消息映射
`handleShortNovelEvent` 改为往统一对话流追加/更新消息:
| SSE 事件 | 对话流行为 |
|---|---|
| `status` | 更新底部 loading 提示文案(不追加独立消息) |
| `clarification_card` | `addResultMessage({role:'assistant', kind:'card', card: payload.card, submitted:false})` |
| `outline_created` | `addResultMessage({role:'assistant', kind:'outline', outline: payload.outline, confirmed:false})` |
| `novel_start` | `addResultMessage({role:'assistant', kind:'novel', content:'', pending:true})` |
| `novel_delta` | 往最后一条 `kind:'novel'` 消息的 `content` 追加 `payload.delta` |
| `novel_done` | 标记最后一条 novel 消息 `pending=false`,保存 `scriptId`/`conversationId`/`currentVersionMessageId` |
| `error` | 若 `DAILY_GENERATION_IN_PROGRESS` 且有 `session_id`:保存 `resumeableSessionId`,更新底部 loading 为失败态+继续创作入口;其他错误:追加 `kind:'text', role:'assistant', failed:true` 消息 |
### 用户交互 → 接口调用
每张卡片/大纲消息内部渲染交互按钮,用户操作时**先追加一条 user 消息到对话流,再调接口**:
| 阶段 | 交互 | 追加 user 消息 | 接口调用 |
|---|---|---|---|
| 生成 | ClarificationCard 提交 | `{kind:'text', content: 答案}` | `followupStream(action:'answer_clarification', payload:{answer})` |
| 生成 | 大纲"确认" | `{kind:'text', content:'确认大纲'}` | `followupStream(action:'confirm_outline')` |
| 生成 | 大纲"修改" | `{kind:'text', content: 修改意见}` | `followupStream(action:'modify_outline', payload:{feedback})` |
| 生成 | 失败"继续创作" | `{kind:'text', content:'继续之前的创作'}` | `followupStream(action:'retry')` |
| 后续 | novel_done 后底部输入 | `{kind:'text', content: 修改建议}` | `streamScriptChat`(旧接口,支持改写/继续/版本) |
### 接口切换边界
- **生成流程**(澄清→大纲→小说):用新接口 `startNovelStream` / `followupStream`
- **novel_done 后**:保存记录后,底部输入栏 `sendChat` 切换用现有旧接口 `streamScriptChat` + `createMessage`(已成熟,支持改写/继续/版本切换)
切换时机:`novel_done` 事件处理时,设置 `generationPhase = 'done'`,底部输入栏根据该标志切换接口。两套接口的消息都进同一个 `resultMessages` 对话流,用户无感知。
## 交互态管理
### 卡片消息(kind:'card'
- 渲染 `ClarificationCard`,传入 `:card="message.card"`
- `submitted=false` 时:正常交互,`@submit` 触发 `submitClarification`
- `submitClarification` 内:设置 `message.submitted=true`(禁用卡片),追加 user 消息,调 followupStream
- `submitted=true` 时:卡片显示"已回答:xxx",禁用交互
### 大纲消息(kind:'outline'
- 渲染大纲内容(title/logline/beats/ending+ 确认/修改按钮
- `confirmed=false` 时:显示输入框+确认+修改按钮
- 用户确认/修改后:设置 `message.confirmed=true`(隐藏按钮),追加 user 消息,调 followupStream
- `confirmed=true` 时:只展示大纲内容,不再有按钮
### 小说消息(kind:'novel'
- `pending=true` 时:流式追加 `novel_delta` 文本,显示打字光标
- `pending=false`(novel_done)时:完整小说正文,无光标,可复制/朗读
## chat 视图模板结构
```vue
<view v-else-if="viewState === 'chat'" class="chat-page">
<!-- 顶部历史 + 关闭 -->
<view class="chat-top-actions">...</view>
<!-- 对话流 -->
<scroll-view class="chat-scroll" scroll-y ...>
<view class="chat-scroll-content">
<view v-for="msg in resultMessages" :key="msg.id">
<!-- user text -->
<view v-if="msg.role==='user' && msg.kind==='text'" class="chat-bubble user">
<text>{{ msg.content }}</text>
<text class="bubble-time">{{ msg.time }}</text>
</view>
<!-- assistant card -->
<view v-else-if="msg.kind==='card'" class="chat-bubble system">
<ClarificationCard
v-if="!msg.submitted"
:card="msg.card"
@submit="answerClarification(msg, $event)"
/>
<view v-else class="card-answered">
<text>已回答{{ msg.answer }}</text>
</view>
</view>
<!-- assistant outline -->
<view v-else-if="msg.kind==='outline'" class="chat-bubble system">
<!-- 大纲内联渲染title/logline/beats/ending复用当前 outlining 视图样式 -->
<view v-if="msg.outline?.title" class="outline-title">{{ msg.outline.title }}</view>
<view v-if="msg.outline?.logline" class="outline-logline">{{ msg.outline.logline }}</view>
<view v-if="Array.isArray(msg.outline?.beats)" class="outline-beats">
<view v-for="(beat, idx) in msg.outline.beats" :key="idx" class="beat-item">
<text class="beat-order">{{ beat.order || idx + 1 }}</text>
<view class="beat-content">
<text class="beat-title">{{ beat.title }}</text>
<text v-if="beat.summary" class="beat-summary">{{ beat.summary }}</text>
</view>
</view>
</view>
<view v-if="msg.outline?.ending" class="outline-ending">
<text class="ending-label">结局</text>
<text class="ending-text">{{ msg.outline.ending }}</text>
</view>
<view v-if="!msg.confirmed" class="outline-actions">
<input v-model="msg.feedback" placeholder="如需修改请输入意见" />
<button @click="modifyOutline(msg)">修改大纲</button>
<button @click="confirmOutline(msg)">确认大纲</button>
</view>
</view>
<!-- assistant novel -->
<view v-else-if="msg.kind==='novel'" class="chat-bubble system">
<text>{{ msg.content }}<text v-if="msg.pending" class="typing-cursor">|</text></text>
</view>
<!-- assistant text (status/error) -->
<view v-else class="chat-bubble system">
<text :class="{ failed: msg.failed }">{{ msg.content }}</text>
</view>
</view>
<!-- 底部 loading -->
<view v-if="generating" class="chat-loading">
<view class="loading-orbit streaming">...</view>
<text>正在为你重写人生</text>
</view>
<!-- 失败继续创作入口 -->
<view v-if="generationStatus==='failed' && resumeableSessionId" class="resume-action">
<button @click="resumeSession">继续创作</button>
</view>
<view :id="resultScrollAnchor" class="chat-scroll-anchor"></view>
</view>
</scroll-view>
<!-- 底部输入栏novel_done 后启用 -->
<view class="chat-input-bar">
<textarea v-model="chatInput" @confirm="sendChat" />
<view @click="sendChat">发送</view>
</view>
</view>
```
## 文件改动清单
| 文件 | 操作 | 说明 |
|---|---|---|
| `mini-program/src/pages/main/ScriptView.vue` | 修改 | 删除 clarifying/outlining/novel-generating 独立视图;新增统一 chat 视图;扩展 addResultMessage 支持 kind/card/outline/submitted/confirmed;改造 handleShortNovelEvent 往对话流追加消息;submitClarification/confirmOutline/modifyOutline 改为操作对话消息;sendChat 按 generationPhase 切换接口 |
| `mini-program/src/components/ClarificationCard.vue` | 不变 | 复用,由父组件控制 submitted 态 |
## 不在本次范围内
- 首页 home 的灵感推荐/语音输入 — 保留不变
- MessageCard 的版本切换/改写/继续功能 — 保留不变(novel_done 后用旧接口时复用)
- 后端接口 — 不变(新接口 followupStream + 旧接口 streamScriptChat 都已就绪)
- 历史剧本库 — 保留不变
## 验收标准
- 用户提交心愿后进入对话页,用户消息作为气泡显示
- 澄清卡片作为 AI 消息出现在对话流,用户回答后卡片变为"已回答"态
- 大纲作为 AI 消息出现,确认/修改后按钮消失
- 小说流式生成作为一条 AI 消息逐字出现
- novel_done 后底部输入栏启用,继续修改用旧接口正常工作
- 失败时(DAILY_GENERATION_IN_PROGRESS)对话流内显示继续创作入口
- 整个过程不切换视图,用户始终在同一个对话页面
@@ -0,0 +1,239 @@
---
author: AI Assistant
created_at: 2026-07-19
purpose: 小程序心愿实现页面的短篇小说生成接口从内部 AI Runtime 迁移到外部 short-novel-service 的设计方案
---
# 短篇小说服务接口迁移设计
## 概述
将小程序"心愿实现"页面(ScriptView.vue)的短篇小说生成流程,从当前内部 AI Runtime 同步调用模式,迁移到外部 short-novel-service`http://49.232.138.53:8010`)的流式 SSE 多轮交互模式。同时开启小程序的手机号短信登录功能(验证码固定 123456)。
## 架构决策
- **架构模式**:后端 SSE 代理 + 前端完整多轮交互适配
- **持久化策略**:小说生成完成后一次性保存(中间交互阶段不持久化)
- **认证方式**:外部服务 API Token 保存在后端配置中,前端不接触
## 当前架构(AS-IS
### 前端流程
```
ScriptView.vue
→ submitWish() → runGeneration()
→ createScriptWithDialogue(payload) // POST /epicScript/createWithDialogue
→ 返回 { scriptId, conversationId, title, plotIntro, plotTurning, plotClimax, plotEnding }
→ loadMessages()
→ viewState = 'result'
```
### 后端流程
```
EpicScriptController.createWithDialogue()
→ EpicScriptDialogueServiceImpl.createWithDialogue()
→ 创建 EpicScript + Conversation 记录
→ invokeScriptGenerate() → aiRuntimeService.test("script_generate") // 同步 AI 调用
→ parseScriptOutput() → 解析 JSON 输出
→ 创建 3 条 Message(系统欢迎、用户输入、AI 回复)
→ 返回 EpicScriptCreateWithDialogueResponse
```
### 当前接口特征
- 同步调用,一次性返回完整结果
- AI 输出为 JSON 格式(title、plotIntro、plotTurning、plotClimax、plotEnding
- 前端无中间交互阶段
## 目标架构(TO-BE
### 前端新流程
```
ScriptView.vue 状态机:
home → clarifying ⇄ outlining → novel-generating → result
```
#### 阶段详情
| 阶段 | 触发事件 | UI 行为 | 用户操作 |
|---|---|---|---|
| `home` | 用户输入心愿 | 展示输入框+灵感推荐+语音按钮 | 输入文本/语音,点击发送 |
| `clarifying` | `clarification_card` SSE 事件 | 展示卡片式问题 | 选择选项或输入文本回答 |
| `outlining` | `outline_created` SSE 事件 | 展示大纲(标题/logline/beats/结局) | 确认大纲 或 提出修改意见 |
| `novel-generating` | `novel_start` SSE 事件 | 逐字流式展示小说正文 | 等待生成完成 |
| `result` | `novel_done` SSE 事件 | 展示完成的小说+后续对话 | 继续对话修改/朗读/分享 |
#### 澄清卡片类型
| card_type | UI 渲染 | 交互方式 |
|---|---|---|
| `single_select` | 单选按钮组 | 选择一个选项 |
| `multi_select` | 多选标签组 | 选择多个选项 |
| `text_input` | 文本输入框 | 自由输入 |
| `mixed` | 选项 + 输入框 | 选择或自由输入 |
### 后端新架构
#### 新增 Controller
```java
@RestController
@RequestMapping("/shortNovel")
public class ShortNovelController {
// 首次发起(SSE 流式代理)
@PostMapping("/stream")
public SseEmitter stream(@RequestBody ShortNovelStreamRequest request);
// 后续轮次(SSE 流式代理)
@PostMapping("/followup")
public SseEmitter followup(@RequestBody ShortNovelFollowupRequest request);
}
```
#### 请求/响应格式
**POST /shortNovel/stream**
入参:
```json
{ "query": "用户的心愿文本" }
```
后端转发到外部服务:
```json
{
"user_id": "当前登录用户ID",
"message_id": "web_{timestamp}",
"query": "用户的心愿文本"
}
```
**POST /shortNovel/followup**
入参:
```json
{
"sessionId": "外部服务返回的 session_id",
"action": "answer_clarification | confirm_outline | modify_outline | retry",
"payload": {
"answer": "用户回答(answer_clarification 时)",
"feedback": "修改意见(modify_outline 时)"
}
}
```
后端转发到外部服务:
```json
{
"session_id": "sessionId",
"user_id": "当前登录用户ID",
"message_id": "web_{timestamp}",
"action": "answer_clarification | confirm_outline | modify_outline | retry",
"payload": { ... }
}
```
#### SSE 事件格式(透传)
外部服务返回的 SSE 事件格式:
```
data: {"type":"status","session_id":"xxx","payload":{"message":"处理中","stage":"clarification"}}
data: {"type":"clarification_card","session_id":"xxx","payload":{"card":{"card_type":"single_select","question":"...","options":[...]}}}
data: {"type":"outline_created","session_id":"xxx","payload":{"outline":{"title":"...","beats":[...]}}}
data: {"type":"novel_start","session_id":"xxx","payload":{}}
data: {"type":"novel_delta","session_id":"xxx","payload":{"delta":"文本片段"}}
data: {"type":"novel_done","session_id":"xxx","payload":{"full_text":"完整小说文本"}}
data: {"type":"error","session_id":"xxx","payload":{"code":"ERROR_CODE","message":"错误描述"}}
```
后端 SseEmitter 代理逐行转发这些事件,不做任何修改。
#### SSE 代理实现
```java
// 使用 RestTemplate 读取外部服务 SSE 流
// 逐行解析 data: 行,通过 SseEmitter.send() 转发
// 超时设置 300 秒
// 错误处理:连接失败、超时、外部服务错误
```
#### 配置
```yaml
# application.yml
short-novel:
api-base-url: http://49.232.138.53:8010
api-token: c67d4a95b0bb92470a24d534302c0d40
connect-timeout: 10000
read-timeout: 300000
```
### 数据持久化
在收到 `novel_done` 事件后,后端执行一次性保存:
1. 从 SSE 流中捕获 `novel_done` 事件的 `full_text`
2. 创建 `EpicScript` 记录
3. 创建 `Conversation` 记录
4. 创建 3 条 `Message` 记录(系统欢迎、用户初始输入、AI 小说全文)
5. 在最后一个 SSE 事件(`novel_done`)中附加 `scriptId``conversationId`
实现方式:在 SSE 代理层拦截 `novel_done` 事件,在转发给前端之前执行保存逻辑,并将 ID 信息注入到事件的 payload 中。
### 短信验证码修复
#### 改动范围
| 文件 | 行号 | 当前值 | 修改为 |
|---|---|---|---|
| `mini-program/src/pages/login/index.vue` | 149 | `'验证码已发送(模拟: 888888'` | `'验证码已发送(模拟: 123456'` |
后端已使用 `DEFAULT_SMS_CODE = "123456"`AuthServiceImpl.java 第 80 行),无需修改。
## 前端新增/修改文件清单
| 文件 | 操作 | 说明 |
|---|---|---|
| `services/shortNovel.js` | 新增 | 短篇小说流式 API 服务 |
| `components/ClarificationCard.vue` | 新增 | 澄清卡片组件 |
| `pages/main/ScriptView.vue` | 修改 | 生成流程改为状态机,新增 clarifying/outlining/novel-generating 阶段 |
## 后端新增/修改文件清单
| 文件 | 操作 | 说明 |
|---|---|---|
| `controller/ShortNovelController.java` | 新增 | SSE 代理接口 |
| `service/ShortNovelService.java` | 新增 | SSE 代理转发逻辑 |
| `service/impl/ShortNovelServiceImpl.java` | 新增 | SSE 代理实现 |
| `dto/request/ShortNovelStreamRequest.java` | 新增 | 首次请求 DTO |
| `dto/request/ShortNovelFollowupRequest.java` | 新增 | 后续轮次 DTO |
| `config/ShortNovelConfig.java` | 新增 | 配置类(api-base-url、api-token |
| `application.yml` | 修改 | 新增 short-novel 配置段 |
| `EpicScriptDialogueServiceImpl.java` | 修改 | 抽取保存逻辑为可复用方法 |
## 错误处理
| 场景 | 处理方式 |
|---|---|
| 外部服务不可达 | SSE 发送 `error` 事件,前端显示"服务暂时不可用" |
| 外部服务超时(>300s | SseEmitter 超时回调,前端显示"生成超时" |
| 外部服务返回错误事件 | 透传 error 事件给前端 |
| 用户中途退出 | 前端关闭 SSE 连接,后端 SseEmitter 自动清理 |
| 小说生成失败 | 外部服务发送 `error` 事件,前端提供"重试"按钮 |
## 不在本次范围内
- 外部服务的知识库图谱(knowledge-graph)功能 — 暂不集成
- 外部服务的 prompt 自定义配置 — 使用外部服务默认配置
- 历史对话的恢复/继续 — 已完成的历史记录按现有方式展示
- 真实的短信发送服务 — 继续使用固定验证码 123456
@@ -0,0 +1,538 @@
---
author: AI Assistant
created_at: 2026-07-21
purpose: 修复 SSE 流式传输不工作(小说生成一次性输出)和历史列表不保存最新生成小说的问题
---
# SSE 流式传输与历史列表保存修复设计
## 问题概述
### 问题 1:历史列表不保存最新生成的小说
**现象**:用户完成小说生成后,历史列表页面没有显示新生成的小说。
**根本原因**
- 后端 `ShortNovelServiceImpl.forwardSse()` 方法中,`novel_done` 事件触发保存数据库的条件是 `originalQuery != null`
- 前端在调用 `followupStream()` 时没有正确传递 `originalQuery` 参数,或传递的是空字符串
- 导致所有通过 followup 接口触发的小说生成(澄清回答、大纲确认/修改、重试)都不会保存到数据库
**代码位置**
- 后端:`server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:165`
- 前端:`mini-program/src/pages/main/ScriptView.vue:1768-1830`
### 问题 2:小说生成没有真正的流式逐字输出
**现象**:小说生成完成后一次性显示全部内容,而不是像 ChatGPT/通义千问那样逐字显示。
**根本原因**
- 后端日志显示所有 `novel_delta` 事件都在同一秒内到达(例如 22:29:26 的所有事件)
- 可能的原因:
1. nginx 缓冲了 SSE 响应(即使设置了 300s 超时,但默认启用了 proxy_buffering
2. OkHttp 的 `BufferedSource.readUtf8Line()` 在某些情况下可能等待直到有足够数据
3. SseEmitter 发送事件时没有显式 flush
- 前端的 `novel_delta` 处理逻辑是正确的(直接累加 delta),但事件一次性到达导致看起来不是流式的
**代码位置**
- 后端:`server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:144-192`
- nginx`/etc/nginx/sites-enabled/lifescript.happylifeos.com.conf`
- 前端:`mini-program/src/pages/main/ScriptView.vue:1715-1722`
## 设计目标
1. **历史列表保存**:确保每次成功的小说生成都保存到数据库,并在历史列表中显示
2. **真正流式输出**:实现像 ChatGPT/通义千问那样的逐字流式输出,用户可以看到文字逐个出现
3. **可观测性**:添加详细日志便于后续问题排查和性能优化
## 技术方案
### 方案 A:彻底重构 SSE 转发逻辑(推荐)
#### 1. 后端 OkHttp 读取优化
**文件**`server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java`
**修改内容**
```java
// 在 forwardSse 方法中添加详细日志
log.info("[ShortNovel SSE] 开始读取上游响应: path={}, sessionId={}", path, currentUserId);
while (!source.exhausted()) {
String line = source.readUtf8LineStrict(); // 使用严格行读取
if (line == null) break;
// 添加时间戳日志
log.debug("[ShortNovel SSE] 读取到行: length={}, timestamp={}",
line.length(), System.currentTimeMillis());
if (line.startsWith("data:")) {
dataBuffer.append(line.substring(5).trim());
} else if (line.isEmpty() && dataBuffer.length() > 0) {
String dataStr = dataBuffer.toString();
dataBuffer.setLength(0);
if ("[DONE]".equals(dataStr)) {
continue;
}
try {
JSONObject event = JSON.parseObject(dataStr);
String type = event.getString("type");
long eventTimestamp = System.currentTimeMillis();
log.info("[ShortNovel SSE] 处理事件: type={}, timestamp={}", type, eventTimestamp);
// 拦截 novel_done 事件,保存到数据库
if ("novel_done".equals(type)) {
JSONObject payload = event.getJSONObject("payload");
log.info("[ShortNovel SSE] novel_done 事件: originalQuery={}, payload={}",
originalQuery, payload != null);
if (payload != null && originalQuery != null && !originalQuery.trim().isEmpty()) {
String fullText = payload.getString("full_text");
if (fullText != null) {
Map<String, Object> metadata = new HashMap<>();
if (payload.get("title") != null) {
metadata.put("title", payload.get("title"));
}
log.info("[ShortNovel SSE] 开始保存小说: userId={}, queryLength={}, textLength={}",
currentUserId, originalQuery.length(), fullText.length());
Map<String, String> saveResult = epicScriptDialogueServiceImpl.saveNovelResult(
currentUserId,
originalQuery,
fullText,
metadata);
log.info("[ShortNovel SSE] 小说保存成功: scriptId={}", saveResult.get("scriptId"));
// 注入 scriptId 到事件中
payload.put("scriptId", saveResult.get("scriptId"));
payload.put("conversationId", saveResult.get("conversationId"));
payload.put("currentVersionMessageId", saveResult.get("currentVersionMessageId"));
} else {
log.warn("[ShortNovel SSE] novel_done 事件缺少 full_text");
}
} else {
log.warn("[ShortNovel SSE] novel_done 事件跳过保存: originalQuery={}, payload={}",
originalQuery, payload);
}
}
// 逐事件转发给前端,并显式 flush
emitter.send(SseEmitter.event().name(type).data(event.toJSONString()));
emitter.send(SseEmitter.event().comment("")); // 触发 flush
} catch (Exception parseEx) {
log.warn("[ShortNovel SSE] 事件解析失败: {}", parseEx.getMessage());
}
}
}
log.info("[ShortNovel SSE] 完成读取上游响应");
```
**关键点**
- 使用 `readUtf8LineStrict()` 替代 `readUtf8Line()`
- `readUtf8Line()` 在遇到 EOF 时可能返回不完整的数据
- `readUtf8LineStrict()` 严格遵循 SSE 规范,遇到 `\n``\r\n` 才返回一行
- 这确保每行数据都是完整的 SSE 事件,避免缓冲导致的数据堆积
- 添加毫秒级时间戳日志,便于诊断事件到达的时间间隔
-`emitter.send()` 后发送空注释事件,触发 SseEmitter 的 flush
- Spring 的 `SseEmitter` 默认会缓冲事件,直到缓冲区满或连接关闭
- 发送空注释 `comment("")` 会强制刷新缓冲区,确保事件立即发送到前端
- 这是实现真正流式传输的关键步骤
- 增加 `originalQuery.trim().isEmpty()` 检查,避免空字符串被误判为有效值
#### 2. nginx 配置优化
**文件**`/etc/nginx/sites-enabled/lifescript.happylifeos.com.conf`
**修改内容**
```nginx
location /api/shortNovel/ {
proxy_pass http://127.0.0.1:19089;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 禁用代理缓冲,确保 SSE 事件立即转发
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_set_header Connection '';
# 超时配置
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
```
**关键点**
- `proxy_buffering off`:禁用 nginx 的响应缓冲
- `proxy_cache off`:禁用缓存
- `chunked_transfer_encoding on`:启用分块传输编码
- `proxy_set_header Connection ''`:清除 Connection 头,避免干扰
#### 3. 前端事件处理优化
**文件**`mini-program/src/pages/main/ScriptView.vue`
**修改内容**
```javascript
// 在 handleShortNovelEvent 中添加日志
const handleShortNovelEvent = (event) => {
const { type, session_id, payload = {} } = event
const timestamp = Date.now()
console.log('[ScriptView] 收到事件:', { type, session_id, timestamp, payload })
if (session_id) novelSessionId.value = session_id
switch (type) {
case 'novel_delta': {
const lastNovel = [...resultMessages.value].reverse().find(m => m.kind === 'novel' && m.pending)
if (lastNovel) {
const delta = payload.delta || ''
console.log('[ScriptView] novel_delta:', {
deltaLength: delta.length,
currentLength: lastNovel.content.length,
timestamp
})
lastNovel.content += delta
}
keepResultAtBottom()
break
}
case 'novel_done': {
console.log('[ScriptView] novel_done:', {
scriptId: payload.scriptId,
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
}
}
}
// 在 startNovelGeneration 中确认 firstQuery 设置
const startNovelGeneration = async (source = 'home') => {
const text = wishText.value.trim()
if (!text) return
// ... 前面的验证代码 ...
firstQuery.value = text // 确认设置首次心愿文本
console.log('[ScriptView] 设置 firstQuery:', { text, length: text.length })
// ... 后面的初始化代码 ...
}
// 在 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,
originalQueryLength: firstQuery.value?.length
})
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'answer_clarification',
payload: { answer },
originalQuery: firstQuery.value,
onEvent: handleShortNovelEvent,
onError: (errMsg) => markGenerationFailed(errMsg)
})
setTimeout(() => { answeringClarification.value = false }, 200)
}
// 在 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
}
}
```
**关键点**
- 添加详细的 console.log,记录每个事件的接收时间
- 确认 `firstQuery.value` 在首次请求时正确设置
- 在所有 followupStream 调用中传递 `originalQuery`
### 方案 B:诊断优先(备选)
如果方案 A 不能解决问题,可以采用方案 B:
1. **添加更详细的诊断日志**
- 后端:记录每个事件的接收时间、发送时间、事件类型、数据长度
- 前端:记录每个事件的接收时间、事件类型、数据长度
- nginx:启用 access log,记录请求和响应的时间戳
2. **分析日志确定瓶颈**
- 后端接收事件的时间间隔
- 后端发送事件的时间间隔
- 前端接收事件的时间间隔
3. **根据诊断结果修复**
- 如果是上游服务问题,考虑联系上游服务提供者
- 如果是 nginx 问题,调整 nginx 配置
- 如果是 OkHttp 问题,考虑更换 HTTP 客户端
### 方案 C:绕过代理层(不推荐)
如果代理层确实无法解决缓冲问题,可以考虑:
1. 前端直接连接上游 SSE 服务(绕过 nginx 和后端)
2. 但这会破坏现有架构,降低安全性,不利于统一监控
**不推荐**此方案,除非方案 A 和 B 都失败。
## 实施步骤
### 第一步:后端修改
1. 修改 `ShortNovelServiceImpl.java`
- 添加详细日志
- 使用 `readUtf8LineStrict()`
-`emitter.send()` 后添加 flush 触发
- 增加 `originalQuery` 的空字符串检查
2. 编译后端:
```bash
cd server
mvn clean install -DskipTests
```
### 第二步:nginx 配置
1. 修改 `/etc/nginx/sites-enabled/lifescript.happylifeos.com.conf`
- 添加 `proxy_buffering off;`
- 添加 `proxy_cache off;`
- 添加 `chunked_transfer_encoding on;`
- 添加 `proxy_set_header Connection '';`
2. 重新加载 nginx
```bash
sudo nginx -t
sudo systemctl reload nginx
```
### 第三步:前端修改
1. 修改 `ScriptView.vue`
- 添加 console.log 日志
- 确认 `firstQuery.value` 正确设置
- 确认所有 followupStream 调用传递 `originalQuery`
2. 构建小程序:
```bash
cd mini-program
npm run build:mp-weixin
```
### 第四步:部署
1. 上传后端 JAR 到服务器:
```bash
scp server/target/server-1.0.0.jar root@101.200.208.45:/data/programs/emotion-museum/
```
2. 重启后端服务:
```bash
ssh root@101.200.208.45 "cd /data/programs/emotion-museum && ./deploy-server.sh test"
```
### 第五步:验证
1. **后端验证**
- 查看日志,确认事件逐条到达(时间间隔 > 100ms)
- 确认 `novel_done` 事件触发保存,日志显示 `小说保存成功`
2. **前端验证**
- 浏览器 Console 查看日志,确认事件逐条到达
- 观察小说生成是否逐字显示
3. **数据库验证**
- 查询 `t_epic_script` 表,确认新记录存在
- 检查历史列表页面,确认显示新生成的小说
## 测试用例
### 测试用例 1:完整生成流程
1. 输入心愿文本
2. 回答澄清问题
3. 确认大纲
4. 等待小说生成完成
5. 验证:
- 前端逐字显示小说内容
- 后端日志显示事件逐条到达
- 数据库中出现新记录
- 历史列表显示新生成的小说
### 测试用例 2:修改大纲后重新生成
1. 输入心愿文本
2. 回答澄清问题
3. 修改大纲
4. 等待小说生成完成
5. 验证:同上
### 测试用例 3:继续之前的创作
1. 输入心愿文本
2. 回答澄清问题
3. 中断流程(关闭页面)
4. 重新进入页面,点击"继续创作"
5. 等待小说生成完成
6. 验证:同上
## 风险评估
### 风险 1:上游服务不支持真正的流式传输
**可能性**:中等
**影响**:即使后端和 nginx 都配置正确,如果上游服务一次性发送所有数据,流式输出仍然不会工作
**缓解措施**
- 添加详细的日志,记录每个事件的接收时间
- 如果日志显示所有事件在同一毫秒到达,说明是上游服务问题
- 联系上游服务提供者,确认是否支持真正的流式传输
### 风险 2nginx 配置不生效
**可能性**:低
**影响**:nginx 仍然缓冲响应,导致流式输出不工作
**缓解措施**
- 使用 `nginx -t` 验证配置语法
- 使用 `curl -N` 测试 SSE 接口,观察是否逐条接收事件
- 检查 nginx 的 error log,确认没有配置错误
### 风险 3SseEmitter flush 不工作
**可能性**:低
**影响**:即使调用了 `emitter.send()`,事件仍然被缓冲
**缓解措施**
- 使用 `emitter.send(SseEmitter.event().comment(""))` 触发 flush
- 如果仍然不工作,考虑更换为 `ResponseBodyEmitter` 或 `StreamingResponseBody`
## 成功标准
1. **流式输出**:小说生成过程中,前端逐字显示内容,用户可以观察到文字逐个出现
2. **历史列表保存**:每次成功的小说生成都保存到数据库,并在历史列表中显示
3. **日志可观测**:后端和前端日志清晰记录每个事件的处理过程,便于问题排查
## 后续优化
1. **性能监控**:添加 Prometheus 指标,监控 SSE 连接的延迟和吞吐量
2. **错误重试**:如果 SSE 连接中断,支持自动重连
3. **离线缓存**:对于生成失败的小说,支持离线缓存,网络恢复后重试
@@ -0,0 +1,217 @@
---
author: AI Assistant
created_at: 2026-07-22
purpose: 修复 ClarificationCard 选项无法切换的 bug
---
# ClarificationCard 选项切换修复设计
## 问题概述
### 现象
在"心愿实现"页面(生成剧本页面),当后端返回澄清卡片(`clarification_card` 事件)时,用户在选项卡中选择了一个选项后,无法切换到其他选项。
### 根本原因
`ClarificationCard.vue` 组件的 `toggleOption` 函数(第 80-95 行)依赖 `card_type` 字段判断是单选还是多选:
```javascript
function toggleOption(opt) {
const value = opt.value
if (isSingle.value) { // 仅当 card_type === 'single_select' 时执行
selectedValues.value = [value]
} else if (isMulti.value) { // 仅当 card_type === 'multi_select' 或 'mixed' 时执行
// 多选逻辑...
}
// 如果 card_type 不是上述任何值,函数什么都不做!
}
```
**关键问题**:如果后端返回的 `card.card_type` 不是 `'single_select'``'multi_select'``'mixed'`(可能是 `undefined``null` 或其他未定义的值),`toggleOption` 函数**不会执行任何逻辑**,导致:
- 用户点击选项 A → `selectedValues` 不更新
- 用户点击选项 B → `selectedValues` 仍不更新
- 视觉上表现为"无法切换选项"
### 代码位置
**文件:** `mini-program/src/components/ClarificationCard.vue:80-95`
## 设计目标
1. **选项可自由切换**:无论 `card_type` 是什么值,用户都能正常切换选项
2. **保持现有功能**:不破坏单选、多选、文本输入的现有行为
3. **向后兼容**:对于正常的 `card_type` 值(`single_select``multi_select``text_input`),行为保持不变
4. **不破坏其他业务功能**:不修改 ScriptView.vue 或后端代码
## 技术方案
### 方案:增强 `toggleOption` 容错性
**核心思路**:将条件判断从"是否为单选"改为"是否为多选或文本输入",如果都不是则默认当作单选处理。
**修改位置:** `mini-program/src/components/ClarificationCard.vue:80-95`
**修改前的代码:**
```javascript
function toggleOption(opt) {
const value = opt.value
if (isSingle.value) {
selectedValues.value = [value]
} else if (isMulti.value) {
const idx = selectedValues.value.indexOf(value)
if (idx >= 0) {
selectedValues.value.splice(idx, 1)
} else {
const maxSel = props.card.max_selections || selectedValues.value.length + 1
if (selectedValues.value.length < maxSel) {
selectedValues.value.push(value)
}
}
}
}
```
**修改后的代码:**
```javascript
function toggleOption(opt) {
const value = opt.value
// 修复:默认当作单选处理(当不是多选也不是文本输入时)
if (isMulti.value) {
// 多选逻辑
const idx = selectedValues.value.indexOf(value)
if (idx >= 0) {
selectedValues.value.splice(idx, 1)
} else {
const maxSel = props.card.max_selections || selectedValues.value.length + 1
if (selectedValues.value.length < maxSel) {
selectedValues.value.push(value)
}
}
} else if (!isTextInput.value) {
// 单选逻辑(默认行为,包括 card_type 未定义或未知的情况)
selectedValues.value = [value]
}
}
```
**关键变更:**
-`if (isSingle.value)` 改为 `else if (!isTextInput.value)`
- 这意味着:只要不是多选(`isMulti.value`)也不是文本输入(`isTextInput.value`),就当作单选处理
- 这样即使 `card_type``undefined``null` 或其他未定义的值,选项也能正常切换
**保留的多选逻辑:**
- 多选条件(`isMulti.value`)保持不变
- 多选逻辑(添加/删除选项)保持不变
- 最大选择数限制(`max_selections`)保持不变
**保留的文本输入逻辑:**
- 文本输入卡片(`isTextInput.value`)不会有选项,所以 `toggleOption` 不会被调用
- 但保留这个判断是为了代码的可读性和未来的扩展性
## 行为对照表
| card_type 值 | 修改前行为 | 修改后行为 |
|--------------|-----------|-----------|
| `'single_select'` | ✅ 单选切换 | ✅ 单选切换(行为不变) |
| `'multi_select'` | ✅ 多选切换 | ✅ 多选切换(行为不变) |
| `'mixed'` | ✅ 多选切换 | ✅ 多选切换(行为不变) |
| `'text_input'` | ✅ 无选项,不调用 | ✅ 无选项,不调用(行为不变) |
| `undefined` / `null` | ❌ 无法切换 | ✅ 默认单选切换 |
| 其他未知值 | ❌ 无法切换 | ✅ 默认单选切换 |
## 实施步骤
### 第一步:修改 ClarificationCard.vue
打开 `mini-program/src/components/ClarificationCard.vue`,定位到第 80-95 行的 `toggleOption` 函数,将其修改为上述"修改后的代码"。
### 第二步:构建小程序
```bash
cd mini-program
npm run build:mp-weixin
```
预期输出:`DONE Build complete.`
### 第三步:提交修改
```bash
git add mini-program/src/components/ClarificationCard.vue
git commit -m "fix: 修复 ClarificationCard 选项无法切换的 bug(增强 toggleOption 容错性)"
```
### 第四步:用户验证
在微信小程序中:
1. 进入"心愿实现"页面
2. 输入心愿文本,触发澄清卡片
3. 点击选项 A(应该被选中)
4. 点击选项 B(应该切换到 B,A 取消选中)
5. 再次点击选项 A(应该切换回 A
6. 验证:选项可以自由切换
## 风险评估
### 风险 1:影响现有正常的卡片类型
**可能性**:低
**缓解措施**
- 修改前的行为对照表显示,所有正常的 `card_type` 值行为不变
- 只改变了未知值的默认行为(从"不响应"改为"单选响应"
### 风险 2:破坏其他业务功能
**可能性**:极低
**缓解措施**
- 只修改了 ClarificationCard.vue 一个文件
- 不修改 ScriptView.vue 的事件处理
- 不修改后端代码
### 风险 3:响应式更新问题
**可能性**:低
**缓解措施**
- `selectedValues.value = [value]` 是 Vue 3 标准的响应式赋值
- 在原有代码中已经使用这种方式,应该是有效的
## 测试用例
### 测试用例 1:单选卡片切换
1. 输入心愿文本
2. 收到澄清卡片(单选类型)
3. 点击选项 A
4. 验证:选项 A 被选中(有 ✓ 标记)
5. 点击选项 B
6. **验证:选项 B 被选中,选项 A 取消选中**(这是修复的关键点)
### 测试用例 2:多选卡片(保持原有行为)
1. 收到澄清卡片(多选类型)
2. 点击选项 A
3. 验证:选项 A 被选中
4. 点击选项 B
5. **验证:选项 A 和 B 都被选中**(多选行为不变)
### 测试用例 3:未知 card_type(修复场景)
1. 后端返回 `card_type: undefined` 或其他未知值
2. 收到澄清卡片
3. 点击选项 A
4. **验证:选项 A 被选中**
5. 点击选项 B
6. **验证:选项 B 被选中,选项 A 取消选中**(这是修复的核心场景)
## 完成标准
1. ✅ ClarificationCard.vue 的 `toggleOption` 函数已修改
2. ✅ 小程序构建成功
3. ✅ 修改已提交到 git
4. ✅ 用户验证:选项可以自由切换
5. ✅ 现有功能(单选、多选、文本输入)未受影响
@@ -0,0 +1,357 @@
---
author: AI Assistant
created_at: 2026-07-22
purpose: 修复 H5 模式下 stream 请求 60 秒超时问题,采用 fetch + ReadableStream + AbortController 方案
---
# H5 模式 Stream 超时修复设计
## 问题概述
### 现象
在小程序 H5 模式下(`http://localhost:5284`),"心愿实现"页面的 stream 请求在约 60 秒后超时,浏览器 DevTools Network 面板显示状态为 `(failed)`。用户无法收到上游服务约 67 秒后产生的 `clarification_card` 事件。
### 根本原因
1. **H5 模式下 `uni.request` 使用浏览器原生 `fetch` API**
2. **浏览器 `fetch` 不支持自定义超时参数**,即使设置了 `timeout: 300000` 也无效
3. **浏览器对 `fetch` 连接有约 60 秒的默认超时**
4. **上游服务响应延迟**:从 `status` 事件到 `clarification_card` 事件约需 67 秒
5. **后果**:前端连接在 60 秒时关闭,服务器在 67 秒尝试发送时遇到 `Broken pipe`
### 服务器日志证据
```
22:09:37 - 上游 status 事件收到并成功转发
22:10:44 - 上游 clarification_card 事件收到
22:10:44 - java.io.IOException: Broken pipe(前端连接已断开)
```
### 代码位置
**文件:** `mini-program/src/services/shortNovel.js`
问题代码片段(第 16-55 行):
```javascript
export const startNovelStream = ({ query, onEvent, onError }) => {
const task = uni.request({
// ...
enableChunked: true,
timeout: 300000, // ← H5 模式下无效
// ...
})
}
```
## 设计目标
1. **H5 模式正确接收延迟事件**:支持 300 秒超时,覆盖上游 67 秒延迟场景
2. **保持现有功能**:不破坏小程序 mp-weixin 模式的现有逻辑
3. **完全兼容性**`onEvent``onError` 回调接口不变,调用方无需修改
4. **真正流式读取**:避免 `responseText` 累积导致的内存问题
## 技术方案
### 方案 AH5 模式使用原生 fetch + ReadableStream(已选定)
**核心思路**:在 H5 环境下绕过 `uni.request`,直接使用浏览器原生的 `fetch` API + `response.body.getReader()` 读取流,并通过 `AbortController` 设置自定义超时。
### 架构设计
**双路径策略**
| 环境 | 实现方式 | 原因 |
|------|---------|------|
| H5(浏览器) | `fetch` + `ReadableStream` + `AbortController` | 完全控制超时,支持真正流式读取 |
| mp-weixin(小程序) | `uni.request` + `enableChunked` | 小程序原生支持,无需修改 |
**环境检测**
```javascript
const isH5 = typeof window !== 'undefined' && typeof window.fetch === 'function'
```
### 文件变更结构
**修改的文件**
1. `mini-program/src/services/shortNovel.js` - 添加 H5 路径实现
**不修改的文件**
- `mini-program/src/pages/main/ScriptView.vue` - 调用接口完全兼容
- `server/**` - 后端无需任何修改
- `nginx.conf` - 服务端配置无需修改
## 实现细节
### H5 核心实现
```javascript
/**
* H5 环境 SSE 流式读取
* 使用浏览器原生 fetch + ReadableStream + AbortController
*/
function h5NovelStream(url, body, onEvent, onError) {
// 1. 创建 AbortController 用于超时控制
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 300000)
// 2. 发起 fetch 请求
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
...getAuthHeader()
},
body: JSON.stringify(body),
signal: controller.signal
}).then(response => {
// 3. 检查响应状态
if (!response.ok) {
clearTimeout(timeoutId)
onError?.(`请求失败: HTTP ${response.status}`)
return
}
// 4. 获取 ReadableStream 读取器
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
// 5. 循环读取流数据
function pump() {
return reader.read().then(({ done, value }) => {
if (done) {
clearTimeout(timeoutId)
// 处理缓冲区残留数据
if (buffer.trim()) h5ConsumeSseText(buffer, onEvent, onError)
return
}
// 6. 解码二进制块为文本
buffer += decoder.decode(value, { stream: true })
// 7. 解析完整的事件(按 \n\n 分隔)
const events = buffer.split('\n\n')
buffer = events.pop() // 最后一个可能不完整,保留到下次
for (const event of events) {
h5ConsumeSseText(event, onEvent, onError)
}
// 8. 继续读取
return pump()
})
}
return pump()
}).catch(err => {
clearTimeout(timeoutId)
if (err.name === 'AbortError') {
onError?.('请求超时(300秒)')
} else {
onError?.(err.message || '网络请求失败')
}
})
// 9. 返回 abort 接口
return {
abort: () => {
clearTimeout(timeoutId)
controller.abort()
}
}
}
```
### SSE 事件解析(H5 专用)
```javascript
/**
* 解析单个 SSE 事件块
* 事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
*/
function h5ConsumeSseText(text, onEvent, onError) {
const lines = text.split('\n')
let dataBuffer = ''
for (const line of lines) {
if (line.startsWith('data:')) {
dataBuffer += line.slice(5).trim()
} else if (line === '' && dataBuffer) {
// 空行表示事件结束
const dataStr = dataBuffer
dataBuffer = ''
if (dataStr === '[DONE]') return
try {
const event = JSON.parse(dataStr)
onEvent?.(event)
} catch (e) {
onError?.(`SSE 解析失败: ${e.message}`)
}
}
}
}
```
### 集成入口
修改 `startNovelStream``followupStream`,在入口处根据环境分发:
```javascript
// 环境检测:H5 模式下存在 window 对象
const isH5 = typeof window !== 'undefined' && typeof window.fetch === 'function'
export const startNovelStream = ({ query, onEvent, onError }) => {
if (isH5) {
return h5NovelStream(
`${getApiBaseUrl()}/shortNovel/stream`,
{ query },
onEvent,
onError
)
}
// 小程序环境:保持原有 uni.request 逻辑
let chunkProcessed = false
const task = uni.request({
url: `${getApiBaseUrl()}/shortNovel/stream`,
method: 'POST',
data: { query },
header: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
...getAuthHeader()
},
enableChunked: true,
timeout: 300000,
success: (res) => {
if (res.statusCode >= 400) {
onError?.(res.data?.message || '请求失败')
return
}
if (!chunkProcessed && typeof res.data === 'string' && res.data) {
consumeSseText(res.data, onEvent, onError)
}
},
fail: (error) => {
onError?.(error.errMsg || '网络请求失败')
}
})
task?.onChunkReceived?.((res) => {
chunkProcessed = true
try {
const text = decodeChunk(res.data)
consumeSseText(text, onEvent, onError)
} catch (error) {
onError?.(error.message || '流式解析失败')
}
})
return task
}
```
`followupStream` 采用完全相同的改造模式,在函数开头添加 H5 分支判断。
## 错误处理策略
| 错误类型 | 处理方式 | 用户提示 |
|---------|---------|---------|
| 网络断开 | 触发 `controller.abort()` | "网络连接中断" |
| 超时(300秒) | `AbortController` 自动触发 | "请求超时(300秒)" |
| HTTP 4xx/5xx | 检查 `response.ok` | 显示后端返回的错误信息 |
| SSE 格式错误 | `try-catch` 包裹 `JSON.parse` | "SSE 解析失败"(不中断流) |
| Reader 读取异常 | 在 `pump()` 内部捕获 | 触发 `onError` 回调 |
## 关键设计决策
1. **超时时间统一 300 秒**:与小程序环境的 `uni.request timeout` 保持一致
2. **事件回调接口不变**`onEvent``onError` 接口完全兼容,调用方无需修改
3. **返回值接口对齐**H5 实现返回 `{ abort }`,与 uni.request 任务对象的 `abort` 方法对齐
4. **不修改后端**:纯前端修改,无需重新部署后端服务
5. **流式读取而非累积**:使用 `ReadableStream` 而非 `responseText`,避免内存累积问题
## 测试验证
### 测试用例 1:H5 模式正常生成
1. 启动 H5 开发服务器:`cd mini-program && npm run dev:h5`
2. 访问 `http://localhost:5284`
3. 进入"心愿实现"页面
4. 输入心愿文本,触发澄清卡片
5. **验证**
- 浏览器 Network 面板显示 stream 请求 200 状态,未超时
- 澄清卡片正常显示
- 完成整个流程后小说正常生成
### 测试用例 2:小程序模式不受影响
1. 构建小程序:`npm run build:mp-weixin`
2. 在微信开发者工具中运行
3. 触发完整的小说生成流程
4. **验证**:原有 `uni.request` 路径正常工作
### 测试用例 3:超时场景验证
1. 修改后端人为延迟 350 秒返回
2. 触发 H5 模式 stream 请求
3. **验证**:300 秒后前端收到 "请求超时(300秒)" 错误提示
### 测试用例 4:错误处理
1. 断网后触发请求
2. **验证**:收到 "网络连接中断" 错误
3. 恢复网络后重试
4. **验证**:请求成功
### 验证通过标准
- ✅ H5 模式 stream 请求在 67 秒后才到达的事件能正常接收
- ✅ 浏览器 Network 面板不再显示 `(failed)` 状态
- ✅ 小程序模式完全不受影响
- ✅ 错误处理覆盖所有边界场景
- ✅ 服务器日志中 `Broken pipe` 错误消失
## 风险评估
### 风险 1:浏览器兼容性
**可能性**:低
**说明**`fetch``ReadableStream``AbortController` 在所有现代浏览器(Chrome 76+、Firefox 71+、Safari 14.1+)中支持。我们假设用户使用现代浏览器。
**缓解措施**:通过 `typeof window.fetch === 'function'` 检测,对不支持的环境降级到原有逻辑或显示明确错误。
### 风险 2:内存累积
**可能性**:低
**说明**:如果服务端发送数据过快,可能导致缓冲区积累。
**缓解措施**:使用 `TextDecoder``stream: true` 模式,并及时清空已处理的 buffer。
### 风险 3:SSE 格式解析错误
**可能性**:中
**说明**:不同服务端实现的 SSE 格式可能有细微差异(如 `\r\n` vs `\n`、注释行等)。
**缓解措施**:使用 `dataBuffer` 累积 `data:` 行,直到遇到空行才解析,符合 SSE 规范。
## 完成标准
1.`shortNovel.js` 添加 H5 路径实现
2.`startNovelStream``followupStream` 根据环境自动分发
3. ✅ H5 模式通过浏览器验证,stream 请求不再超时
4. ✅ 小程序模式通过验证,未受影响
5. ✅ 服务器日志中 `Broken pipe` 错误消失
6. ✅ 修改提交到 git
## 后续优化建议
1. **统一超时配置**:将超时时间(300 秒)提取为常量
2. **添加重试机制**:网络断开后自动重试
3. **心跳检测**:定期发送心跳包检测连接活性
4. **性能监控**:记录流式读取的延迟和吞吐量
@@ -0,0 +1,52 @@
---
author: AI Assistant
created_at: 2026-07-26
purpose: 修复 ScriptLibraryView 章节数展示为 0 的问题
---
# 章节数展示修复设计
## 问题
ScriptLibraryView 列表页中所有剧本的章节数均显示为"0章"。
**根因**:前端 `getChapterCount` 函数读取 `script.chapterCount || script.chapters || 0`,但后端未返回 `chapterCount``chapters` 字段;老剧本的 `plotJson` 仅包含 `title``fullContent`,不存在 `stages.outline.beats` 结构。
## 目标
- 新剧本(Task 1-5 部署后生成):从 `plotJson.stages.outline.beats.length` 获取真实章节数
- 老剧本:根据 `script.length` 字段(short/medium/long)估算合理的章节数
- 短篇显示"1章"(符合业务预期)
## 方案
修改 `mini-program/src/pages/main/ScriptLibraryView.vue` 中的 `getChapterCount` 函数:
```javascript
const getChapterCount = (script) => {
// 优先从 beats 数组长度获取(新剧本结构)
const beats = script?.plotJson?.stages?.outline?.beats
if (Array.isArray(beats) && beats.length > 0) return beats.length
// Fallback:根据 length 字段估算
const length = script?.length
if (length === 'short') return 1
if (length === 'medium') return 2
if (length === 'long') return 4
return 1
}
```
## 影响范围
- 仅修改 `ScriptLibraryView.vue``getChapterCount` 函数
- 不涉及后端改动
- 不涉及其他前端文件
## 验收标准
- 短篇剧本列表卡片显示"1章"
- 中篇剧本列表卡片显示"2章"
- 长篇剧本列表卡片显示"4章"
- 新剧本(有 beats 数据)显示真实 beats 数量
- Console 无新增错误
@@ -0,0 +1,75 @@
---
author: AI Assistant
created_at: 2026-07-26
purpose: 修复详情页只显示 2 个澄清选项卡(应为 N 个)的后端累积丢失 bug
---
# 详情页澄清选项卡显示不全修复设计
## 问题
生成小说时,生成页显示 N 个澄清选项卡(clarification cards),但进入详情页后只显示前 2 个。必须保证详情页选项卡数量与生成页完全一致。
## 根因分析
通过数据库查询(t_message 按 conversation_id 分组统计)确认:3 个有 clarification 的 conversation 全部是 **clarification_question=2 + clarification_answer=3**。即用户回答了 3 次(3 个 answer),但后端只保存了 2 个 question。**丢失的是第一个 clarification_cardQ1**。
DB 中 conversation `339751895901937665` 的 messageOrder 分布:
| order | type | content |
|---|---|---|
| 1 | clarification_answer | 同事/上司 |
| 2 | clarification_question | 是什么具体事件让你决定不再讨好? |
| 3 | clarification_answer | 功劳被抢 |
| 4 | clarification_question | 你希望主角如何反击? |
| 5 | clarification_answer | 暗中布局 |
| 6 | outline | ... |
**根因链**
1. **上游 SSE 事件结构**`session_id` 字段在事件**顶层**(与 `type` 同级),不在 `payload` 内。前端从顶层取(`ScriptView.vue:1800` `const { type, session_id, payload } = event`)且工作正常。
2. **后端 status 事件处理**`ShortNovelServiceImpl.forwardSse` 行 312-314 从 `payload.getString("session_id")` 取,**取不到**。
3. **首次 stream 的 currentSessionId[0] 初始为 null**`forwardSse` 行 253 `final String[] currentSessionId = {knownSessionId}`,首次 stream 调用时 `knownSessionId=null`
4. **首次 stream 的 Q1 不累积**:首次 stream 里第一个 `clarification_card` 事件到达时 `currentSessionId[0]` 仍为 null,行 346 的 `if (currentSessionId[0] != null)` 为 falseQ1 不被 `accumulateStage` 累积。
5. **followup 流正常**followup 调用 `forwardSse``knownSessionId = request.getSessionId()`(前端传的,非 null),后续 Q2/Q3 都正确累积。
6. **drainStages 取到不完整的 stagesHistory**novel_done 时 drainStages 取到 `[A1, Q2, A2, Q3, A3, outline]`(缺 Q1),saveNovelResult 创建 2 question + 3 answer。
7. **详情页只能渲染 2 个 card**:从 DB 读到的 clarification_question 只有 2 条。
## 修复方案
**修改文件**`server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java`
**修改点**`forwardSse` 方法的事件循环(行 305 附近),在解析 event 后、status 处理之前,**从事件顶层取 `session_id` 同步 `currentSessionId[0]`**(与前端 `ScriptView.vue:1805-1806` 取 sessionId 的方式对齐)。
**新增代码**(放在 `String type = event.getString("type");` 之后、status 事件 `if ("status".equals(type))` 之前):
```java
// 从事件顶层取 session_id 同步 sessionId
// 上游事件结构与前端对齐:session_id 与 type 同级,不在 payload 内
// 修复:首次 stream 里 status 处理从 payload 取不到 session_id,导致 currentSessionId 保持 null
// 第一个 clarification_card 因 sessionId 为 null 不累积,详情页少显示第一个选项卡
String topSessionId = event.getString("session_id");
if (topSessionId != null && !topSessionId.isEmpty()) {
currentSessionId[0] = topSessionId;
}
```
## 影响范围
-`forwardSse` 方法新增 5 行代码
- 零现有逻辑改动(status 处理保留原样作为次选,顶层取 sessionId 优先覆盖)
- 零数据库变更
- 零前端改动
## 预期效果
- **新剧本**:首次 stream 的 Q1 不再丢失,详情页显示全部 N 个选项卡(与生成页完全一致)
- **老剧本**3 个已有 conversation):保持现状,仍显示 2 个 card。DB 中 stagesHistory 缺失的 Q1 已永久丢失,无法真实恢复。
## 验收标准
1. 在 H5 手动完成一次新的小说生成(包含至少 2 轮澄清)
2. 进入该剧本的详情页
3. 验证选项卡数量与生成页完全一致(生成 N 个,详情显示 N 个)
4. Console 无新增错误
5. 本地 `mvn clean install` 编译通过
@@ -0,0 +1,531 @@
---
author: Claude
created_at: 2026-07-26
purpose: 修复小程序心愿实现(小说生成)页面数据保存不完整、历史列表看不到最新小说、详情页无法完整复现全流程的方案设计
---
# 心愿实现小说保存与复现完整性修复设计
## 1. 概述
修复小程序"心愿实现"ScriptView → 短篇小说生成)页面存在的多个严重问题:
- 生成的小说没有完整、正常地保存到数据库
- 历史列表页(ScriptLibraryView)看不到最新生成的小说
- 详情页无法完整复现用户心愿、澄清选择、大纲、AI 生成内容、对话修改历史
- 当前保存逻辑只在 `novel_done` 事件触发一次性保存,中间步骤全部丢失
## 2. 现状分析
### 2.1 现有数据保存链路(AS-IS)
```
[小程序 ScriptView.vue]
用户输入心愿 → POST /shortNovel/stream (SSE)
[后端 ShortNovelServiceImpl]
代理外部 short-novel-service → 拦截 novel_done 事件
调用 saveNovelResult() 一次性保存 6 条数据
[数据库] 1 EpicScript + 1 Conversation + 3 Message
```
### 2.2 已识别的根因
| # | 严重度 | 根因 | 影响 |
|---|------|------|------|
| 1 | 🔴 高 | `saveNovelResult` 缺少 `@Transactional` 注解 | 6 次写操作无原子性,中途失败留下脏数据 |
| 2 | 🔴 高 | DDL 与 Entity 不一致:`t_epic_script``conversation_id`/`current_version_message_id` 列;`t_message``script_id` 列 | INSERT/UPDATE 失败,异常被吞,导致数据丢失 |
| 3 | 🔴 高 | `originalQuery` 为空时 `saveNovelResult` 直接返回不保存 | followup 阶段生成的小说永远不会落库 |
| 4 | 🔴 高 | 中间交互(澄清卡片、大纲确认)没有持久化为消息 | 详情页无法复现全流程 |
| 5 | 🟡 中 | `saveNovelResult` 写死 `length="short"` / `style="career"` | 列表按 length/style 过滤时看不到 |
| 6 | 🟡 中 | 列表查询 `getListByCurrentUser` 触发隐性写操作(`ensureConversationForScript`) | 读路径产生写操作,违反分层原则 |
| 7 | 🟡 中 | `create_time` 排序依赖 `MetaObjectHandler`,若失效则 NULL 排序异常 | 新记录可能排到末尾 |
| 8 | 🟡 中 | 前端 `novel_done``fetchScripts()` 刷新不可靠(未 await) | 列表可能不包含最新记录 |
## 3. 目标架构(TO-BE
### 3.1 核心策略
**实时分阶段保存 + 最终补全:**
- 每轮交互(澄清回答、大纲确认)完成后立即保存为消息
- `novel_done` 时补全最终的小说正文消息 + 更新 script 元数据
- 每个保存操作独立事务,互不影响
### 3.2 完整消息流程
| message_order | sender | type | 触发时机 | content |
|---|---|---|---|---|
| 1 | system | system | INIT | 欢迎消息 |
| 2 | user | chat | INIT | 用户心愿原文(firstQuery |
| 3 | assistant | clarification | CLARIFICATION | AI 澄清卡片(metadata=card JSON |
| 4 | user | chat | CLARIFICATION_ANSWER | 用户对澄清的回答 |
| 5 | assistant | outline | OUTLINE | AI 大纲(metadata=outline JSON |
| 6 | user | chat | OUTLINE_CONFIRM | 用户确认/修改 |
| 7 | assistant | script | NOVEL_DONE | 完整小说正文(currentVersionMessageId |
| 8+ | user/assistant | chat | 后续对话 | 修改历史 |
## 4. 数据模型修复
### 4.1 DDL 补全(SQL migration
```sql
-- 补 t_epic_script 缺失列
ALTER TABLE t_epic_script
ADD COLUMN conversation_id BIGINT NULL COMMENT '关联对话ID',
ADD COLUMN current_version_message_id BIGINT NULL COMMENT '当前版本消息ID';
-- 补 t_message 缺失列
ALTER TABLE t_message
ADD COLUMN script_id BIGINT NULL COMMENT '关联剧本ID(冗余反查)';
-- 补 t_conversation 缺失列(如果需要)
-- ALTER TABLE t_conversation ADD COLUMN current_version_message_id BIGINT NULL;
```
### 4.2 Entity 校验
确认 `EpicScript.java``Message.java``Conversation.java` 的字段与 DDL 一致:
- `EpicScript.conversationId``t_epic_script.conversation_id`
- `EpicScript.currentVersionMessageId``t_epic_script.current_version_message_id`
- `Message.scriptId``t_message.script_id`
### 4.3 Message.type 枚举扩展
新增 message type 值:
- `clarification` — AI 澄清卡片
- `outline` — AI 大纲
- `script` — 完整小说正文(已有)
- `chat` — 普通对话(已有)
- `system` — 系统消息(已有)
## 5. 后端修复
### 5.1 新增 `NovelSessionService`
负责在 Redis 中缓存 `sessionId → sessionMeta` 的映射:
- `originalQuery`(用户首次输入的心愿原文)
- `style``length`
- `conversationId``scriptId``userId`
**目的:** 保证 followup 流程中即使前端没传 `originalQuery`,也能从 session 恢复。
### 5.2 重构 `saveNovelResult` → 分阶段保存
#### 5.2.1 新方法签名
```java
@Service
public class NovelStageService {
/**
* 分阶段保存小说生成过程
* @param userId 当前用户ID
* @param sessionId 外部 short-novel-service 返回的 session_id
* @param stage 当前阶段
* @param stageData 阶段数据
*/
public StageSaveResult saveStage(Long userId, String sessionId, NovelStage stage, Map<String, Object> stageData);
}
public enum NovelStage {
INIT, // 首次发起,初始化 script + conversation + 首条 user 消息
CLARIFICATION, // AI 返回澄清卡片
CLARIFICATION_ANSWER, // 用户回答澄清
OUTLINE, // AI 返回大纲
OUTLINE_CONFIRM, // 用户确认/修改大纲
NOVEL_DONE // AI 返回完整小说正文(最终补全)
}
```
#### 5.2.2 每个 stage 的保存动作
**INIT**
- 创建 `EpicScript`id=雪花ID, userId, title="生成中...", theme=originalQuery, style, length, plotJson={}
- 创建 `Conversation`id=雪花ID, userId, userType="registered", scriptId, title, type="script", status="active"
- 创建 Message 1system 欢迎)
- 创建 Message 2user 心愿原文)
- 更新 EpicScript.conversationId
- 缓存 sessionMeta 到 Redis
- 返回 `{scriptId, conversationId}`
**CLARIFICATION**
- 创建 Message 3assistant, type=clarification, content="(见 metadata)", metadata=card JSON
- 返回 `{messageId: 3}`
**CLARIFICATION_ANSWER**
- 创建 Message 4user, type=chat, content=answer
- 返回 `{messageId: 4}`
**OUTLINE**
- 创建 Message 5assistant, type=outline, content="(见 metadata)", metadata=outline JSON
- 更新 EpicScript.plotJson.outline = outline 数据
- 返回 `{messageId: 5}`
**OUTLINE_CONFIRM**
- 创建 Message 6user, type=chat, content=feedback/confirmation
- 返回 `{messageId: 6}`
**NOVEL_DONE(最终补全):**
- 创建 Message 7assistant, type=script, content=fullText, versionNumber=1, metadata=完整正文+章节 JSON
- 更新 EpicScript.title = outline.title 或生成默认标题
- 更新 EpicScript.currentVersionMessageId = message 7 的 id
- 更新 Conversation.currentMessageId = message 7 的 id
- 返回 `{messageId: 7, scriptId, conversationId}`
#### 5.2.3 事务保证
每个 stage 独立事务(`@Transactional(rollbackFor = Exception.class)`):
- 单个 stage 失败不影响其他 stage
- 已保存的中间数据不会丢失
- 失败的 stage 可通过 retry 重试
### 5.3 修复 `originalQuery` fallback
```java
// ShortNovelServiceImpl.followup() 中
String originalQuery = request.getOriginalQuery();
if (StringUtils.isBlank(originalQuery)) {
originalQuery = novelSessionService.getOriginalQuery(sessionId);
}
if (StringUtils.isBlank(originalQuery)) {
throw new BizException("originalQuery 不能为空且无法从 session 恢复");
}
```
### 5.4 修复 length/style 硬编码
- `ShortNovelStreamRequest` 增加 `style``length` 字段
- `ShortNovelFollowupRequest` 增加 `style``length` 字段
- 前端在首次发起时传 style/length,后端透传给外部服务
- `saveNovelResult` 不再写死 `length="short"` / `style="career"`
### 5.5 列表查询修复
#### 5.5.1 `getListByCurrentUser` 修复
```java
public List<EpicScript> getListByCurrentUser(EpicScriptListRequest request) {
Long currentUserId = UserContextHolder.getCurrentUserId();
LambdaQueryWrapper<EpicScript> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(EpicScript::getUserId, currentUserId)
.eq(EpicScript::getIsDeleted, 0);
// 只在显式传入时才过滤
if (StringUtils.isNotBlank(request.getStyle())) {
wrapper.eq(EpicScript::getStyle, request.getStyle());
}
if (StringUtils.isNotBlank(request.getLength())) {
wrapper.eq(EpicScript::getLength, request.getLength());
}
if (StringUtils.isNotBlank(request.getKeyword())) {
wrapper.and(w -> w.like(EpicScript::getTitle, request.getKeyword())
.or().like(EpicScript::getTheme, request.getKeyword()));
}
// create_time 倒序,NULL 排最后
wrapper.orderByDesc(EpicScript::getCreateTime);
wrapper.last("NULLS LAST"); // 或在 Mapper 中使用 coalesce
return epicScriptMapper.selectList(wrapper);
}
```
#### 5.5.2 移除隐性写操作
```java
// 移除 ensureConversationForScript 中的写操作
// 仅查询 script,不在列表查询时补建 conversation
```
#### 5.5.3 校验 `MetaObjectHandler`
确认 `MybatisMetaObjectHandler` 正确填充 `createTime`/`updateTime`
- 若 handler 缺失或失效,需要补全实现
- 字段上加 `@TableField(fill = FieldFill.INSERT)`
## 6. 前端修复
### 6.1 状态管理增强
`stores/app.js` 新增字段:
- `currentSessionMeta: { sessionId, scriptId, conversationId, originalQuery, style, length }`
- 提供 setter `setSessionMeta(meta)` 和 getter `getSessionMeta()`
### 6.2 ScriptView.vue 修复
#### 6.2.1 原始参数持久化
```javascript
const runGeneration = async ({ prompt, source }) => {
// 首次发起前,保存原始参数
store.setSessionMeta({
originalQuery: prompt,
style: userStyle.value,
length: userLength.value,
timestamp: Date.now()
})
// 启动 SSE 流
await startNovelStream({ query: prompt, style: ..., length: ..., onEvent: handleShortNovelEvent })
}
```
#### 6.2.2 可靠刷新列表
```javascript
const handleNovelDone = async (payload) => {
scriptId.value = payload.scriptId
conversationId.value = payload.conversationId
// await 确保刷新完成
try {
await store.fetchScripts()
} catch (e) {
console.error('剧本列表刷新失败:', e)
// 显示重试按钮
}
isGenerating.value = false
}
```
#### 6.2.3 followup 时 originalQuery 兜底
```javascript
const submitClarification = async (answer) => {
const meta = store.getSessionMeta()
if (!meta?.originalQuery) {
uni.showToast({ title: '原始心愿已丢失,请重新开始', icon: 'none' })
return
}
await followupStream({
sessionId: meta.sessionId,
action: 'answer_clarification',
payload: { answer },
originalQuery: meta.originalQuery,
style: meta.style,
length: meta.length,
onEvent: handleShortNovelEvent
})
}
```
#### 6.2.4 错误处理增强
```javascript
const handleNovelError = (error) => {
isGenerating.value = false
// 显示明确错误提示
uni.showModal({
title: '生成失败',
content: error.message || '请稍后重试',
showCancel: false,
confirmText: '知道了'
})
}
```
### 6.3 ScriptLibraryView.vue 修复
#### 6.3.1 默认不传 length/style 过滤
```javascript
onMounted(async () => {
await store.fetchScripts() // 默认查询全部
})
```
#### 6.3.2 列表项展示优化
```vue
<view class="script-card">
<view class="script-title">{{ script.title }}</view>
<view class="script-meta">
<text class="tag-style">{{ script.style }}</text>
<text class="tag-length">{{ script.length }}</text>
<text class="word-count">{{ script.wordCount }}</text>
</view>
<view class="script-time">{{ formatDate(script.createTime) }}</view>
</view>
```
### 6.4 ScriptDetailView.vue 增强
进入详情页加载所有消息,按 message_order 顺序完整复现:
```vue
<template>
<view class="script-detail">
<!-- 1. 用户心愿原文 -->
<view v-if="userWishMessage" class="message-item user">
<view class="message-label">💭 用户心愿</view>
<view class="message-content">{{ userWishMessage.content }}</view>
</view>
<!-- 2. AI 澄清卡片 -->
<view v-if="clarificationMessage" class="message-item assistant">
<view class="message-label">🤔 AI 澄清</view>
<ClarificationCard :card="clarificationMessage.metadata" readonly />
</view>
<!-- 3. 用户对澄清的回答 -->
<view v-if="clarificationAnswerMessage" class="message-item user">
<view class="message-label">💬 澄清回答</view>
<view class="message-content">{{ clarificationAnswerMessage.content }}</view>
</view>
<!-- 4. AI 大纲 -->
<view v-if="outlineMessage" class="message-item assistant">
<view class="message-label">📋 大纲</view>
<OutlineView :outline="outlineMessage.metadata" />
</view>
<!-- 5. 用户确认/修改 -->
<view v-if="outlineConfirmMessage" class="message-item user">
<view class="message-label"> 大纲确认</view>
<view class="message-content">{{ outlineConfirmMessage.content }}</view>
</view>
<!-- 6. 完整小说正文 -->
<view v-if="scriptMessage" class="message-item assistant novel">
<view class="message-label">📖 小说正文</view>
<Markdown :content="scriptMessage.content" />
</view>
<!-- 7. 后续对话修改历史 -->
<view v-for="msg in chatHistoryMessages" :key="msg.id" class="message-item">
...
</view>
<!-- 继续修改按钮 -->
<button @click="continueModify">继续修改</button>
</view>
</template>
```
```javascript
const loadFullScript = async (scriptId) => {
const script = store.getScriptById(scriptId) || (await store.fetchScripts(), store.getScriptById(scriptId))
if (!script?.conversationId) return
const res = await listMessagesByConversation({
conversationId: script.conversationId,
includeVersions: false
})
const messages = (res.data || []).sort((a, b) => a.messageOrder - b.messageOrder)
userWishMessage.value = messages.find(m => m.messageOrder === 2)
clarificationMessage.value = messages.find(m => m.messageOrder === 3)
clarificationAnswerMessage.value = messages.find(m => m.messageOrder === 4)
outlineMessage.value = messages.find(m => m.messageOrder === 5)
outlineConfirmMessage.value = messages.find(m => m.messageOrder === 6)
scriptMessage.value = messages.find(m => m.messageOrder === 7)
chatHistoryMessages.value = messages.filter(m => m.messageOrder > 7)
}
```
## 7. 修改文件清单
### 后端新增文件
| 文件 | 说明 |
|------|------|
| `service/NovelStageService.java` | 分阶段保存服务 |
| `service/impl/NovelStageServiceImpl.java` | 分阶段保存实现 |
| `service/NovelSessionService.java` | session 元数据缓存 |
| `service/impl/NovelSessionServiceImpl.java` | Redis 缓存实现 |
| `enums/NovelStage.java` | 阶段枚举 |
### 后端修改文件
| 文件 | 修改内容 |
|------|---------|
| `entity/EpicScript.java` | 确认 conversationId/currentVersionMessageId 字段映射 |
| `entity/Message.java` | 确认 scriptId 字段映射 + 扩展 type 枚举 |
| `service/impl/EpicScriptDialogueServiceImpl.java` | 重构 saveNovelResult 为分阶段保存 |
| `service/impl/ShortNovelServiceImpl.java` | 拦截所有 stage 事件,调用 saveStage |
| `service/impl/EpicScriptServiceImpl.java` | 修复 getListByCurrentUser 过滤逻辑 + 移除隐性写 |
| `dto/request/ShortNovelStreamRequest.java` | 增加 style/length 字段 |
| `dto/request/ShortNovelFollowupRequest.java` | 增加 style/length 字段 |
| `sql/migration/2026-07-26-novel-save-completion.sql` | DDL 补列 |
### 前端修改文件
| 文件 | 修改内容 |
|------|---------|
| `stores/app.js` | 增加 currentSessionMeta 字段 + setter/getter |
| `pages/main/ScriptView.vue` | 增强 originalQuery 持久化、可靠刷新、错误处理 |
| `pages/main/ScriptLibraryView.vue` | 默认不传过滤参数 + 列表项展示优化 |
| `pages/main/ScriptDetailView.vue` | 完整复现全流程消息 |
## 8. 验收标准(强制)
### 8.1 验收前置条件
- ✅ 后端代码 `mvn clean install -DskipTests` 编译通过
- ✅ 后端 `python deploy-remote.py backend` 部署成功,服务器无报错
- ✅ 小程序 H5 模式启动成功(`http://localhost:5180`
### 8.2 端到端验收流程(H5 浏览器)
#### 场景 1:完整流程生成小说
1. 打开 `http://localhost:5180`
2. 进入"心愿实现"页面
3. 输入心愿文本(如"我想成为一个优秀的产品经理")
4. 点击"生成"按钮
5. **验证:** AI 弹出澄清卡片,UI 显示"已保存"状态
6. 选择/输入澄清回答
7. **验证:** 后端数据库 t_message 新增 message_order=4 的 user 消息
8. AI 生成大纲
9. **验证:** 后端数据库 t_message 新增 message_order=5 的 assistant 消息 + EpicScript.plotJson.outline 更新
10. 确认大纲
11. **验证:** 后端数据库 t_message 新增 message_order=6 的 user 消息
12. AI 流式生成小说正文
13. **验证:** 完成后 t_message 新增 message_order=7 的 assistant 消息(type=script
14. **验证:** EpicScript.currentVersionMessageId 已回写到 message 7 的 id
15. 进入"剧本"library)列表
16. **验证:** 列表显示刚刚生成的小说
17. 点击该小说进入详情
18. **验证:** 详情页按顺序显示:用户心愿 → 澄清卡片 → 澄清回答 → 大纲 → 大纲确认 → 完整小说正文
19. **验证:** 浏览器 Console 无错误
20. **验证:** Network 面板所有 API 返回 200
#### 场景 2:中途刷新页面(断点恢复)
1. 走到澄清回答后,刷新页面
2. **验证:** 重新进入"心愿实现"页面,状态恢复(不要求完全恢复,但要能查到已保存的消息)
#### 场景 3:多次生成小说
1. 连续生成 3 个不同心愿的小说
2. **验证:** 列表按时间倒序显示 3 条记录
3. **验证:** 每条记录详情页完整复现
#### 场景 4:对话修改流程
1. 在剧本 chat 视图输入修改意见
2. **验证:** 后端保存消息
3. **验证:** 列表和详情同步更新
### 8.3 通过标准
- ✅ 所有场景 1-4 通过
- ✅ 浏览器 Console 无任何新增错误
- ✅ Network 面板所有 API 返回 200 或业务预期状态码
- ✅ 服务器端日志无 ERROR
- ✅ H5 页面完整验收通过才可标记任务完成
### 8.4 不通过处理
任何场景不通过都必须立即停止,修复后重新部署、重新验收,禁止"部分完成"标记。
## 9. 不在本次范围内
- 外部 short-novel-service 接口调整
- AI 生成质量优化
- 收藏、分享等附加功能
- 真实的语音朗读、语音识别集成
- 多端(小程序非 H5 模式)适配(H5 已能覆盖小程序核心功能)
@@ -0,0 +1,298 @@
---
author: huazhongmin
created_at: 2026-07-26
purpose: 小说列表页和详情页展示改造设计 — 修复详情页重复展示、列表页增加大纲预览、后端保存澄清问答消息
---
# 小说列表页 / 详情页展示改造设计
## 1. 背景与问题
### 1.1 当前问题
**详情页重复展示**`ScriptDetailView.vue` 当前结构存在内容重复:
- hero-card 显示 `script.summary`(正文前 90 字 — 小说正文开头)
- tabs 正文 tab 显示 `script.content`(完整小说正文)
- hero-card 显示 `userWish = script.theme`(用户心愿)
- 结果:用户看到小说正文展示了两次(summary + fullContent),加上用户心愿,看起来"重复展示了两遍"
**列表页缺少大纲**`ScriptLibraryView.vue` 当前只显示标题、标签、正文摘要,没有展示小说的大纲结构。
**澄清问答未保存**:后端 `saveNovelResult` 只创建 3 条 messagesystem 欢迎、user 心愿、assistant 小说正文)。生成过程中的澄清问答(3 轮 Q/A)没有保存为 message,导致详情页无法还原完整生成流程。
### 1.2 核心设计原则
> **"生成的时候是什么样展示的,后续进去详情页面就要怎么样展示"**
详情页必须完全复刻生成时 `ScriptView.vue` 的 chat-bubble 展示结构,不发明新 UI。
---
## 2. 改造范围
| 模块 | 改造内容 | 复杂度 |
|---|---|---|
| 后端 `ShortNovelServiceImpl` | 保存 clarification_question / clarification_answer / outline 消息到 conversation.messages | 高 |
| 后端 新增 API | `/conversation/{conversationId}/messages` 返回消息列表 | 低 |
| 前端 `ScriptDetailView.vue` | 移除 hero-card + tabs,改为 chat-bubble 流程视图(复用 ScriptView 结构) | 高 |
| 前端 `ScriptLibraryView.vue` | 新增大纲预览行 | 低 |
| 前端 store/service | 新增 `fetchConversationMessages` 函数 | 低 |
---
## 3. 数据层改造
### 3.1 后端 Message 保存(ShortNovelServiceImpl
**现状**`saveNovelResult` 只在 `novel_done` 时创建 3 条 message。澄清问答没有保存。
**改造**:在 SSE 转发逻辑中增加 message 保存:
| SSE 事件 | 触发的 message 保存 |
|---|---|
| `clarification_card` | 创建 assistant messagetype=`clarification_question`, sender=`assistant`, content=问题 JSON |
| followup `answer_clarification` | 创建 user messagetype=`clarification_answer`, sender=`user`, content=用户答案) |
| `outline_created` | 创建 assistant messagetype=`outline`, sender=`assistant`, content=大纲 JSON 摘要) |
| `novel_done` | 保持现有 3 条 message 不变(system 欢迎 + user 心愿 + assistant 小说正文) |
**messageOrder 策略**:按事件到达顺序递增。每个 message 用 `snowflakeIdGenerator.nextIdAsString()` 生成 ID。
**message metadata**clarification_question 消息的 metadata 字段存储完整的 card JSON(包含 question、options 等),用于详情页还原 ClarificationCard 组件。
### 3.2 前端 API 层
**新增**`getMessagesByConversation(conversationId)` — 调用 `/conversation/{conversationId}/messages` 返回按 messageOrder 排序的 message 列表。
**后端新增接口**
```java
@GetMapping("/conversation/{conversationId}/messages")
public Result<List<MessageResponse>> getMessages(@PathVariable String conversationId)
```
**注意**:需检查是否已有类似接口(如 `listMessagesByConversation` in scriptChat.js)。如已存在则复用。
### 3.3 前端 Store 层
**新增**`fetchConversationMessages(conversationId)` — 调用 API 并返回消息列表。
---
## 4. 详情页改造(ScriptDetailView.vue
### 4.1 核心思路
**不发明新 UI,直接复用 ScriptView.vue 的 chat-bubble 结构**
| 生成时 ScriptView | 详情页 ScriptDetailView |
|---|---|
| `ClarificationCard` 组件(可交互) | 复用 `ClarificationCard` 组件(只读模式) |
| `kind: 'outline'` beats 渲染 | 复用相同 beats 渲染逻辑 |
| `kind: 'novel'` novel-text 渲染 | 升级为 Markdown 渲染(详情页不需要流式) |
| chat-bubble user/system 样式 | 完全复用相同样式 |
| chat-input-bar(用户输入) | 移除(详情页只读) |
| outline 修改/确认按钮 | 移除(详情页只读) |
### 4.2 数据加载与消息重建
扩展 ScriptView.vue 已有的"从历史剧本重建 resultMessages"逻辑(line 1449-1500),让它完整还原生成时的消息流:
```javascript
const rebuildResultMessages = async (script, messages) => {
const result = []
// 1. 用户心愿(首条 user 消息)
if (script.theme) {
result.push({ role: 'user', kind: 'text', content: script.theme })
}
// 2. 按 messageOrder 遍历 messages,转换为对应 kind
messages.forEach(m => {
if (m.type === 'clarification_question') {
result.push({ role: 'assistant', kind: 'card', card: parseCard(m) })
} else if (m.type === 'clarification_answer') {
const lastCard = [...result].reverse().find(x => x.kind === 'card')
if (lastCard) {
lastCard.answer = m.content
lastCard.submitted = true
}
} else if (m.type === 'outline') {
result.push({ role: 'assistant', kind: 'outline', outline: parseOutline(m) })
} else if (m.type === 'script') {
result.push({ role: 'assistant', kind: 'novel', content: m.content })
}
})
return result
}
```
### 4.3 UI 布局
```
┌─ 顶部栏 ───────────────────────────┐
│ ‹ 人生剧本 ✦ 继续 │
├──────────────────────────────────────┤
│ │
│ ┌─ chat-bubble user ────────────┐ │
│ │ 我想开一家自己的咖啡馆... │ │
│ └──────────────────────────────┘ │
│ │
│ ┌─ chat-bubble system (card) ───┐ │
│ │ [ClarificationCard 只读模式] │ │
│ │ Q: 你的咖啡馆有什么特色? │ │
│ │ 已回答:安静的阅读空间 │ │
│ └──────────────────────────────┘ │
│ │
│ ┌─ chat-bubble system (outline) ┐ │
│ │ 咖啡与书页之间 │ │
│ │ 简介:一个爱书人的咖啡馆故事 │ │
│ │ ① 辞职的决定 │ │
│ │ ② 街角邂逅 │ │
│ │ 结局:新的开始 │ │
│ └──────────────────────────────┘ │
│ │
│ ┌─ chat-bubble system (novel) ──┐ │
│ │ [Markdown 渲染小说正文] │ │
│ │ 你把那份商业计划书放在... │ │
│ └──────────────────────────────┘ │
│ │
├──────────────────────────────────────┤
│ 返回列表 ▶ 播放 继续生成 │
└──────────────────────────────────────┘
```
### 4.4 只读模式处理
- **ClarificationCard**:所有卡片都显示"已回答"状态,隐藏选项按钮
- **Outline**:隐藏"修改大纲"/"确认大纲"按钮
- **Novel**:用 Markdown 组件渲染(比生成时的纯文本更美观)
- **无 chat-input-bar**:详情页底部不显示输入框
### 4.5 降级策略
- **老剧本无 clarification 消息**:流程中跳过澄清步骤,只展示 wish → outline → novel
- **messages API 失败**:降级用 `plotJson.stages` 重建(只有最后 1 轮澄清)
- **messages 为空**:至少展示 wishtheme+ novelfullContent
---
## 5. 列表页增强(ScriptLibraryView.vue
### 5.1 现状卡片结构
```
┌──────────────────────────────────────┐
│ [封面] │ 标题 [中篇] │
│ 字 │ [标签] [标签] │
│ │ 正文前90字摘要... │
│ │ 5章 | 3.2k字 | 07-26 │
└──────────────────────────────────────┘
```
### 5.2 增强后的卡片结构
```
┌──────────────────────────────────────┐
│ [封面] │ 标题 [中篇] │
│ 字 │ [标签] [标签] │
│ │ 📋 第1章:辞职 · 第2章:邂逅 │ ← 新增大纲预览行
│ │ 正文前80字摘要... │
│ │ 5章 | 3.2k字 | 07-26 │
└──────────────────────────────────────┘
```
### 5.3 大纲预览数据源
`script.plotJson.stages.outline.beats` 取前 2 章标题:
```javascript
const getOutlinePreview = (script) => {
const beats = script.plotJson?.stages?.outline?.beats
if (!Array.isArray(beats) || beats.length === 0) return ''
const parts = beats.slice(0, 2).map((beat, i) => {
return `${i + 1}章:${beat.title || ''}`
})
return `📋 ${parts.join(' · ')}`
}
```
### 5.4 模板变更
在 tag-row 和 summary 之间新增大纲预览行:
```vue
<view v-if="getOutlinePreview(script)" class="outline-preview-row">
<text class="outline-preview-text">{{ getOutlinePreview(script) }}</text>
</view>
<text class="summary">{{ script.summary || '一段正在生成中的平行人生剧本。' }}</text>
```
### 5.5 样式
```css
.outline-preview-row {
margin-top: 10rpx;
padding: 10rpx 14rpx;
border-radius: 14rpx;
background: rgba(168, 85, 247, 0.08);
border: 1rpx solid rgba(168, 85, 247, 0.16);
}
.outline-preview-text {
font-size: 22rpx;
color: rgba(193, 134, 255, 0.88);
line-height: 1.4;
}
```
### 5.6 降级
- **无大纲数据**:不显示大纲预览行(v-if 控制)
- **beats 不足 2 章**:有几章显示几章
- **beats 标题为空**:显示"第1章"(无冒号后缀)
---
## 6. 错误处理与兼容性
### 6.1 老剧本兼容
老剧本(改造前生成的)conversation.messages 只有 3 条(system 欢迎、user 心愿、assistant 小说正文),没有 clarification 和 outline 消息。
**处理**:详情页重建消息流时,跳过缺失的 clarification/outline 步骤。对于大纲数据,fallback 到 `plotJson.stages.outline`(如果存在)。
### 6.2 API 失败降级
- messages API 失败 → 用 `plotJson.stages` 重建(只有最后 1 轮澄清)
- 整个 script API 失败 → 显示错误页面,不渲染流程
### 6.3 空数据降级
- 无 theme → 不显示心愿气泡
- 无 outline → 不显示大纲气泡
- 无 content/fullContent → 显示"暂无正文"占位
---
## 7. 验收标准
### 7.1 H5 端到端验收(强制)
1. **生成新小说**:在 ScriptView 页面生成一篇新小说,观察生成时的展示(心愿 → 澄清 → 大纲 → 正文)
2. **返回列表**:进入 ScriptLibraryView,确认新小说卡片显示大纲预览行
3. **进入详情**:点击新小说进入 ScriptDetailView,确认展示与生成时一致(chat-bubble 流程视图)
4. **老剧本兼容**:点击一个改造前生成的老小说,确认不报错、正常展示(可跳过澄清步骤)
5. **浏览器 Console**:确认无任何新增错误
### 7.2 mp-weixin 产物重建
代码改造完成后,执行 `npm run dev:mp-weixin` 重新构建 mp-weixin 产物,确保微信开发者工具中的真实用户也能看到改造效果。
---
## 8. 不在范围内
- 不改造 ScriptView.vue 的生成流程 UI(保持现状)
- 不改造后端 SSE 转发逻辑(只在关键事件点新增 message 保存)
- 不新增澄清问答的轮数限制(保持上游服务决定的轮数)
- 不改造列表页的搜索、排序、筛选功能
@@ -0,0 +1,376 @@
---
author: AI Assistant
created_at: 2026-07-28
purpose: 恢复小程序小说生成页和详情页丢失的"复制、播放、折叠/展开"功能
---
# 小说消息操作按钮恢复设计
## 问题背景
小程序的小说生成页(ScriptView.vue)和详情页(ScriptDetailView.vue)在之前的重构过程中丢失了"复制、播放、折叠/展开"等操作按钮。用户要求完整恢复这些功能,且必须有真实实现,不能有任何 mock 或占位。
## 根因分析
### 重构历史追溯
通过 git 历史分析,发现功能丢失的关键节点:
1. **commit 9b3006d**(2026-06-27):添加消息卡片功能方法
- 新增 `copyMessageContent(message)`:复制消息内容
- 新增 `playMessageTts(message)`:播放 TTS 音频
- 新增 `toggleMessageCollapse(message)`:折叠/展开消息
2. **commit 0db434c**2026-06-27):在 result-chat-list 中添加功能按钮组
- 在每条 assistant 消息下方添加 5 个按钮:复制、换个方向、不像我、继续生成、播放
- 使用 `.message-actions` 样式类
3. **commit c2ed051**ScriptView 复用 MessageCard 组件
- 将消息卡片提取为独立的 `MessageCard.vue` 组件
- MessageCard 包含完整功能:折叠、复制、换个方向、不像我、续写、改写、查看历史、删除、播放
4. **commit 2c778fa**2026-07-01):**ScriptView 删除 read 模式 + 兼容旧模式,统一对话流入口**
- 删除了 102 行代码,包括所有使用 MessageCard 的调用
- 模板中改为裸的 `<view class="chat-bubble">` 渲染消息
- **MessageCard 导入保留但不再使用**(行 241)
- **JS 函数保留**`copyMessageContent`(行 422-438)、`playMessageTts`(行 440-447)、`toggleMessageCollapse`(行 409-420)、`getMessageDisplayContent`(行 402-407)、`isMessageCollapsed`(行 399
- **`collapsedMessageIds` ref 保留**(行 287
- **按钮组 UI 丢失**
5. **ScriptDetailView.vue**:从未使用过 MessageCard,也从未有过这些功能
### 当前状态
**ScriptView.vue(生成页)**
- ✅ 所有相关函数已存在(copyMessageContent、playMessageTts、toggleMessageCollapse 等)
-`collapsedMessageIds` ref 已存在
- ❌ 模板中按钮组 UI 已丢失
- ❌ 相关样式可能已删除或无用
**ScriptDetailView.vue(详情页)**
- ❌ 完全没有相关函数
- ❌ 完全没有 `collapsedMessageIds` ref
- ❌ 完全没有按钮组 UI
- ✅ 底部有全局播放按钮(`ttsPlayer.playSource`
## 设计方案
### 核心设计决策
| 维度 | 决策 |
|------|------|
| **功能范围** | 完整恢复所有按钮功能,但布局采用极简设计 |
| **页面范围** | 生成页(ScriptView+ 详情页(ScriptDetailView |
| **应用对象** | 仅 `kind === 'novel'` 的小说正文消息 |
| **按钮布局** | 每条 novel 消息下方:折叠/展开 + 复制 + 播放(3 个按钮)|
| **修订类按钮** | 放在底部操作区(已有),不在每条消息下重复 |
### 设计原则
1. **极简设计**:每条 novel 消息下方只放 3 个核心功能按钮(折叠/展开、复制、播放)
2. **功能就近**:内容操作类按钮(复制、播放、折叠)贴近消息,用户操作直观
3. **修订集中**:方向修订类按钮(换个方向、不像我、继续生成)集中在底部操作区,避免重复
4. **样式统一**:两个页面使用相同的按钮组样式,保持视觉一致性
5. **代码复用**ScriptView 已有函数直接复用,ScriptDetailView 新增相同逻辑
### 功能实现细节
#### 1. 折叠/展开
**状态管理**
```javascript
const collapsedMessageIds = ref({})
```
**核心函数**
```javascript
// 判断消息是否折叠
const isMessageCollapsed = (message) => {
return Boolean(collapsedMessageIds.value[message.id])
}
// 获取消息显示内容(折叠时截断)
const getMessageDisplayContent = (message) => {
const content = String(message?.content || '')
if (!isMessageCollapsed(message)) return content
if (content.length <= 200) return content
return `${content.slice(0, 200)}...`
}
// 切换折叠状态
const toggleMessageCollapse = (message) => {
collapsedMessageIds.value = {
...collapsedMessageIds.value,
[message.id]: !isMessageCollapsed(message)
}
analytics.track('script_message_collapse_toggle', {
message_id: message?.id || '',
collapsed: collapsedMessageIds.value[message.id]
}, { eventType: 'script', pagePath })
}
```
**UI 交互**
- 折叠时:显示前 200 字符 + "展开全文"按钮
- 展开时:显示完整内容 + "收起全文"按钮
- 折叠/展开按钮放在消息内容下方,按钮组上方
#### 2. 复制
**核心函数**
```javascript
const copyMessageContent = (message) => {
const content = String(message?.content || '')
if (!content.trim()) {
uni.showToast({ title: '暂无可复制内容', icon: 'none' })
return
}
uni.setClipboardData({
data: content,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
}
})
analytics.track('script_message_copy_click', {
message_id: message?.id || '',
content_length: content.length
}, { eventType: 'script', pagePath })
}
```
**UI 交互**
- 点击"复制"按钮,调用 `uni.setClipboardData`
- 成功复制后显示 Toast 提示"已复制"
- 内容为空时显示"暂无可复制内容"
#### 3. 播放
**核心函数**
```javascript
const playMessageTts = (message) => {
const scriptId = currentResult.value?.id || '' // ScriptView
// 或 script.value?.id || '' // ScriptDetailView
ttsPlayer.playSource(scriptId)
analytics.track('script_message_tts_click', {
message_id: message?.id || '',
script_id: scriptId
}, { eventType: 'script', pagePath })
}
```
**UI 交互**
- 播放图标动态显示:`▶`(未播放)/ `Ⅱ`(播放中)
- 播放文本动态显示:`播放`(未播放)/ `暂停`(播放中)
- 点击按钮调用 `ttsPlayer.playSource(scriptId)` 播放整个小说的 TTS 音频
### UI 设计
**每条 novel 消息下方按钮组**
```
┌─────────────────────────────────────┐
│ [小说正文内容] │
│ (折叠时显示前 200 字...) │
│ │
│ [▼ 展开全文] ← 折叠/展开按钮 │
│ │
│ ┌──────────┬──────────┬──────────┐ │
│ │ 📋 复制 │ ▶ 播放 │ ⚙ 更多 │ │ ← 操作按钮组
│ └──────────┴──────────┴──────────┘ │
└─────────────────────────────────────┘
```
**实际实现(3 个按钮)**
```
┌─────────────────────────────────────┐
│ [小说正文内容] │
│ │
│ [▼ 展开全文] │
│ │
│ ┌──────────┬──────────┬──────────┐ │
│ │ 复制 │ ▶ 播放 │ 收起 │ │
│ └──────────┴──────────┴──────────┘ │
└─────────────────────────────────────┘
```
**按钮样式**
- 参考 MessageCard 的 `result-actions` 样式(grid 布局)
- 3 个按钮横向排列,等宽分布
- 播放按钮为 `primary` 样式(紫色渐变,突出显示)
- 折叠/展开按钮为独立行,放在按钮组上方
- 按钮高度:72rpx
- 按钮圆角:28rpx
- 按钮背景:`rgba(88, 28, 135, 0.18)`
- 按钮边框:`1rpx solid rgba(192, 132, 252, 0.35)`
### 页面差异化
| 页面 | 现有修订类按钮 | 需新增功能 |
|------|--------------|-----------|
| **生成页(ScriptView** | 底部输入区已有"换个方向"、"不像我"等按钮 | 每条 novel 消息下加 3 个按钮(复用已有函数) |
| **详情页(ScriptDetailView** | 底部已有"继续生成"按钮 | 每条 novel 消息下加 3 个按钮 + 新增所有函数 |
### 应用对象
**只在 `kind === 'novel'` 的 assistant 消息上显示按钮组**
-`kind === 'novel'`:小说正文,需要复制、播放、折叠/展开
-`kind === 'card'`:澄清卡片,有特殊交互(提交按钮),不需要这些按钮
-`kind === 'outline'`:大纲,结构化数据,不需要这些按钮
-`kind === 'text'`:用户消息,不需要这些按钮
## 数据流与代码复用
### ScriptView(生成页)
**已有资源**
-`collapsedMessageIds` ref(行 287
-`isMessageCollapsed(message)` 函数(行 399
-`getMessageDisplayContent(message)` 函数(行 402-407
-`toggleMessageCollapse(message)` 函数(行 409-420
-`copyMessageContent(message)` 函数(行 422-438
-`playMessageTts(message)` 函数(行 440-447
-`ttsPlayer` composable(行 320
-`ttsActionText` computed(行 986-990
-`ttsActionIcon` computed(行 992-994
**需要补充**
- ❌ 模板中按钮组 UI(在 `kind === 'novel'` 的消息下方添加)
- ❌ 按钮组样式(`.message-actions``.action-btn` 等)
### ScriptDetailView(详情页)
**需要新增**
-`collapsedMessageIds` ref
-`isMessageCollapsed(message)` 函数
-`getMessageDisplayContent(message)` 函数
-`toggleMessageCollapse(message)` 函数
-`copyMessageContent(message)` 函数
-`playMessageTts(message)` 函数
- ❌ 模板中按钮组 UI
- ❌ 按钮组样式
**已有资源**
-`ttsPlayer` composable(行 110
-`detailTtsActionText` computed(行 118-122
-`detailTtsIcon` computed(行 124-126
## 实现步骤
### 步骤 1ScriptView 恢复按钮组 UI
1. 打开 `mini-program/src/pages/main/ScriptView.vue`
2. 定位到 `kind === 'novel'` 的消息渲染部分(约行 180-187)
3.`<view v-else-if="msg.kind === 'novel'" class="chat-bubble system novel-bubble">` 内部:
-`<Markdown :content="msg.content" />` 改为 `<Markdown :content="getMessageDisplayContent(msg)" />`
- 在 Markdown 下方添加折叠/展开按钮
- 在折叠/展开按钮下方添加操作按钮组(复制、播放)
4. 添加按钮组样式(`.message-actions``.action-btn` 等)
### 步骤 2ScriptDetailView 新增函数
1. 打开 `mini-program/src/pages/main/ScriptDetailView.vue`
2.`<script setup>` 中添加:
- `const collapsedMessageIds = ref({})`
- `isMessageCollapsed(message)` 函数
- `getMessageDisplayContent(message)` 函数
- `toggleMessageCollapse(message)` 函数
- `copyMessageContent(message)` 函数
- `playMessageTts(message)` 函数
### 步骤 3ScriptDetailView 添加按钮组 UI
1. 定位到 `kind === 'novel'` 的消息渲染部分(约行 74-76
2.`<view v-else-if="msg.kind === 'novel'" class="chat-bubble system novel-bubble">` 内部:
-`<Markdown :content="msg.content" />` 改为 `<Markdown :content="getMessageDisplayContent(msg)" />`
- 在 Markdown 下方添加折叠/展开按钮
- 在折叠/展开按钮下方添加操作按钮组(复制、播放)
3. 添加按钮组样式(与 ScriptView 保持一致)
### 步骤 4H5 端到端验收
1. 启动 mini-program H5`python dev-services.py start mini-program`(端口 5180
2. 浏览器访问 `http://localhost:5180/#/pages/main/index?tab=script`
3. **生成页验收**
- 进入一个已有小说的生成页(或新建一个)
- 验证小说正文下方有 3 个按钮:折叠/展开、复制、播放
- 点击"折叠":内容截断显示前 200 字 + "展开全文"按钮
- 点击"展开":内容完整显示 + "收起全文"按钮
- 点击"复制"Toast 提示"已复制",剪贴板中有内容
- 点击"播放"TTS 开始播放,按钮变为"暂停"
- 浏览器 Console 检查:0 新增错误
4. **详情页验收**
- 进入小说详情页(从列表页点击剧本)
- 验证小说正文下方有 3 个按钮:折叠/展开、复制、播放
- 重复上述验收步骤
- 浏览器 Console 检查:0 新增错误
## 验收标准
### 功能验收
-**生成页(ScriptView**
- 每条 novel 消息下方有 3 个按钮:折叠/展开、复制、播放
- 折叠/展开功能正常:折叠时显示前 200 字,展开时显示完整内容
- 复制功能正常:点击后 Toast 提示"已复制",剪贴板中有内容
- 播放功能正常:点击后 TTS 开始播放,按钮变为"暂停"
- Console 无新增错误
-**详情页(ScriptDetailView**
- 每条 novel 消息下方有 3 个按钮:折叠/展开、复制、播放
- 折叠/展开功能正常
- 复制功能正常
- 播放功能正常
- Console 无新增错误
### 样式验收
- ✅ 按钮组横向排列,3 个按钮等宽分布
- ✅ 播放按钮为 primary 样式(紫色渐变)
- ✅ 折叠/展开按钮为独立行,在按钮组上方
- ✅ 按钮样式与 MessageCard 保持一致(圆角、背景、边框)
### 数据流验收
- ✅ 埋点正常触发:`script_message_collapse_toggle``script_message_copy_click``script_message_tts_click`
- ✅ TTS 播放状态同步:播放时按钮显示"暂停",暂停时按钮显示"播放"
## 影响范围
### 修改文件
1. `mini-program/src/pages/main/ScriptView.vue`
- 模板:为 `kind === 'novel'` 的消息添加按钮组 UI
- 样式:添加 `.message-actions``.action-btn` 等样式
2. `mini-program/src/pages/main/ScriptDetailView.vue`
- Script:新增 6 个函数/ref
- 模板:为 `kind === 'novel'` 的消息添加按钮组 UI
- 样式:添加 `.message-actions``.action-btn` 等样式
### 不修改的部分
- ❌ 后端代码:不涉及
- ❌ 数据库:不涉及
- ❌ MessageCard.vue 组件:不修改(保持现状)
- ❌ 其他页面:不涉及
### 兼容性
- ✅ 老剧本(无 clarification/outline 数据):不影响,仍可正常显示
- ✅ 新剧本(有完整数据):新增按钮组功能
- ✅ 无小说正文的剧本:不显示按钮组(`kind === 'novel'` 的消息不存在)
## 风险与注意事项
1. **TTS 播放的是整个小说**`ttsPlayer.playSource(scriptId)` 播放的是整个小说的音频,不是单条消息的音频。这是历史设计,保持一致。
2. **折叠阈值**:折叠时显示前 200 字符。这个阈值可以根据实际需求调整。
3. **样式一致性**:两个页面的按钮组样式必须保持一致,建议直接复制 ScriptView 的样式到 ScriptDetailView。
4. **性能考虑**`collapsedMessageIds` 是响应式对象,每次切换折叠状态都会触发重新渲染。对于大量消息的场景,可能需要优化(当前场景下消息数量有限,不构成问题)。
5. **埋点完整性**:所有用户操作(折叠、复制、播放)都必须触发埋点,用于数据分析。
## 总结
本设计方案通过极简设计恢复小程序小说生成页和详情页丢失的"复制、播放、折叠/展开"功能。核心决策是:每条 novel 消息下方只放 3 个核心功能按钮,修订类按钮集中在底部操作区。实现上,ScriptView 复用已有函数,ScriptDetailView 新增相同逻辑。验收标准明确,影响范围可控。
@@ -0,0 +1,249 @@
---
author: AI Assistant
created_at: 2026-07-21
purpose: 记录 SSE 流式传输修复的测试结果与发现的额外问题
---
# SSE 流式传输修复 - 测试结果
**测试时间:** 2026-07-21
**测试方法:** 后端日志分析(生产服务器 101.200.208.45
---
## 一、后端日志分析结果
### 1.1 修改生效检查
日志格式验证:**通过**
日志中包含新添加的标签 `[ShortNovel SSE]`,确认后端修改已部署到生产服务器。
**日志示例**
```
2026-07-21 22:39:10 [pool-2-thread-1] INFO com.emotion.service.impl.ShortNovelServiceImpl -
[ShortNovel SSE] 收到事件: type=novel_delta, session_id=sess_3b943085816f466aa49e,
event_ts=2026-07-21T14:39:07.363166030Z, keys=[type, payload, timestamp, session_id]
```
### 1.2 事件流式情况分析(核心验证项)
**验证结果:通过(真流式传输已生效)**
| 指标 | 实测值 | 是否达标 |
|------|--------|---------|
| 同一会话的 novel_delta 总数 | **888 个** | 真流式 |
| novel_done 事件数 | **3 个** | 正常 |
| 单次会话事件时间跨度 | 约 3 秒(14:39:07.363 → 14:39:10.558 | 真流式 |
| 事件到达间隔 | 微秒到毫秒级,分散分布 | **真流式** |
**关键时间戳样本**session_id=sess_3b943085816f466aa49e):
- `2026-07-21T14:39:07.363166030Z`
- `2026-07-21T14:39:07.469673058Z`(间隔约 106ms
- `2026-07-21T14:39:08.144305721Z`
- `2026-07-21T14:39:09.274217251Z`
- `2026-07-21T14:39:09.775992660Z`
- `2026-07-21T14:39:10.549778832Z`
- `2026-07-21T14:39:10.558824365Z`novel_done
**结论**:事件时间戳**分散**(非同一秒内全部到达),证明后端的 OkHttp 流式读取生效,真实地将上游的流式数据**逐条**转发给小程序前端。
### 1.3 novel_done 事件后的保存情况
**验证结果:部分失败**
观察到 3 个 `novel_done` 事件,对应 session_id
1. `sess_3e656460d5944a63b3d6`22:17:05
2. `sess_1c89fbf49ef748d5a322`22:29:26
3. `sess_3b943085816f466aa49e`22:39:10
但日志中**未找到**与这些会话对应的"保存"日志(如 `小说保存成功`)。
---
## 二、关键问题发现
### 2.1 严重问题:Bean 创建失败导致服务整体不可用
**时间:** 2026-07-21 22:45:15
**日志原文**
```
2026-07-21 22:45:15 [main] WARN o.s.b.w.s.c.AnnotationConfigServletWebServerApplicationContext -
Exception encountered during context initialization - cancelling refresh attempt:
org.springframework.beans.factory.UnsatisfiedDependencyException:
Error creating bean with name 'shortNovelController':
Unsatisfied dependency expressed through field 'shortNovelService';
nested exception is ... BeanInstantiationException:
Failed to instantiate [com.emotion.service.impl.ShortNovelServiceImpl]:
Constructor threw exception;
nested exception is java.lang.NullPointerException:
Cannot invoke "com.emotion.config.ShortNovelConfig.getConnectTimeout()" because "this.config" is null
at com.emotion.service.impl.ShortNovelServiceImpl.<init>(ShortNovelServiceImpl.java:54)
2026-07-21 22:45:15 [main] ERROR org.springframework.boot.SpringApplication -
Application run failed
```
**根因分析**
查看 `G:\IdeaProjects\emotion-museun\server\src\main\java\com\emotion\service\impl\ShortNovelServiceImpl.java` 第 54 行附近:
```java
@Autowired
private ShortNovelConfig config; // 第 46 行
/**
* OkHttp 客户端:连接/读取超时与 Spring 配置对齐,支持 SSE 长连接流式读取
* 延迟初始化,避免 @Autowired 注入前 config 还未填充
*/
private volatile OkHttpClient okHttpClient; // 第 55 行
private OkHttpClient getOkHttpClient() { // 第 57 行
if (okHttpClient == null) {
synchronized (this) {
if (okHttpClient == null) {
okHttpClient = new OkHttpClient.Builder()
.connectTimeout(config.getConnectTimeout(), TimeUnit.MILLISECONDS) // NPE 在这里
...
```
`config` 字段使用 `@Autowired` 注入。`okHttpClient` 通过懒加载惰性初始化,理论上不会在构造函数阶段触发 NPE。但在实际启动中,**22:45:15 出现了一次完整的应用重启失败**,`Application run failed` 表明 Spring 容器初始化失败。
**最可能的原因**
1. **应用 22:45:15 时被重启**(deploy 之后?),重启过程中触发 NPE
2. **存在第二个 Bean 实例化顺序问题**:测试日志中的 `[pool-2-thread-1]` 表明 OKHTTP 调用在 `pool` 中,但构造函数报错说明 `config` 注入早期 Bean 解析存在问题
3. **或两次部署之间存在某次失败回滚**
**影响评估**
- 在 22:45:15 之后的请求(23:01:31、23:01:45 的 `POST /api/shortNovel/stream``/followup`)虽然能在日志中看到 `JWT 拦截器处理请求`,但**实际无法处理**(Bean 创建失败)。前端在用户实际测试中可能表现为接口超时或 500 错误。
**建议排查**
1. 检查 22:45:15 前后是否有重启记录,确认是发布重启还是异常重启
2. 使用 `git log --since="2026-07-21 22:00" --until="2026-07-21 23:00"` 查看部署记录
3. 检查 `ShortNovelConfig` 类是否有 `@ConfigurationProperties` 注册问题
4. 查看完整堆栈,找到真正的根因(不是 `this.config is null`,而是注入失败)
### 2.2 数据库最新记录分析
**查询结果**`t_epic_script` 表最新 5 条记录的 `create_time`
| id | title | create_time |
|----|-------|-------------|
| 329987360219471872 | 我的人生剧本 | 2026-06-29 22:11:58 |
| f82818b702d5ad4a2fd544f05dd5afd4 | 高考 | 2026-06-28 10:24:44 |
| a3dadb7e85a6c75bea4d2fe4783a7f13 | 我高考了 | 2026-06-28 10:06:30 |
| 49ae0f432253aaff2ded1a00ce23cfb0 | 我中了100w | 2026-06-28 00:51:08 |
| 597df70a446dfe733a403b4ce3b0a354 | 我中了100w | 2026-06-28 00:34:25 |
**结论****未发现 2026-07-21 当天的新增记录**。即使用户在 22:39:10 成功触发了 `novel_done` 事件,**数据库中没有持久化新小说**。
可能的解释:
1. **`novel_done` 事件处理逻辑中没有触发保存**:可能保存逻辑写在别处(如 `outline_created` 处理时),需要进一步分析
2. **保存调用失败被吞**:被 try-catch 静默吞掉的异常(违反项目规范)
3. **Bean 创建失败影响后续请求**22:45:15 后的 Bean 失败导致 23:xx 的请求全部失败
4. **保存路径走的是另一张表**:可能实际保存到了 `t_epic_script_dialogue` 而非 `t_epic_script`
**需要进一步排查**
- 查找 `outline_created` 事件的处理逻辑
- 查看 `EpicScriptDialogueServiceImpl.saveNovel` 或类似方法是否被调用
- 检查是否所有 `novel_done` 处理都有对应的日志
- 验证 mini-program 端 `generateShortNovel()` 或类似 API 路径
### 2.3 Broken pipe 警告
**日志**
```
2026-07-21 22:17:05 [pool-2-thread-1] WARN com.emotion.service.impl.ShortNovelServiceImpl -
SSE 事件解析失败: java.io.IOException: Broken pipe
```
**含义**:客户端(小程序端)中途关闭了连接。可能原因:
- 用户在生成过程中切换页面
- H5 模式下浏览器关闭了 SSE 连接
- 网络不稳定
**风险**:当前 Broken pipe 被 try-catch 捕获,**仅警告而不中断后续处理**,符合项目规范。
---
## 三、测试结论
### 3.1 修复完成度
| 测试用例 | 结果 |
|---------|------|
| **用例1:完整生成流程(SSE流式)** | **通过** - 888 个 novel_delta 事件,时间戳分散 |
| **用例2novel_done 触发保存** | **未通过** - 数据库无新增记录 |
| **用例3:服务稳定性(无 NPE** | **部分失败** - 22:45:15 出现 Bean 初始化失败 |
### 3.2 整体状态
- **后端 OkHttp 流式读取:** 修复生效,真流式传输已实现
- **nginx 缓冲配置:** 未经直接验证(无法在 CI 环境测试小程序)
- **小程序前端体验:** **仍需用户在小程序中手动验证**实际逐字显示效果
- **后端数据库保存:** **存在严重问题** - novel_done 事件未触发数据库持久化
### 3.3 用户手动验证清单
请用户在微信小程序中完成:
1. **流式显示验证**
- 输入心愿文本 → 点击生成
- 观察小说是否**逐字逐句**显示(而非一次性出现)
- 实时控制台查看 event 频率
2. **历史列表验证**
- 生成完成后返回历史页面
- 检查最新生成的小说**是否出现在列表顶部**
- 点击进入查看内容是否完整
3. **重试/继续创作验证**
- 选择"继续之前的创作"或"修改大纲重新生成"
- 验证流式重新开始 + 最终保存
- 历史列表是否正确更新
### 3.4 紧急建议
由于发现 **Bean 创建失败**22:45:15)和 **数据库无新增记录** 两个严重问题:
**强烈建议立即处理**
1. 回滚本次部署,或先修复 `ShortNovelConfig` 注入问题
2. 紧急排查 `novel_done``t_epic_script` 持久化链路
3. 通过 `python scripts/fetch-remote-logs.py` 拉取完整堆栈追踪根因
4. 在 H5 模式下进行端到端真实测试(curl + 浏览器)
---
## 四、附录 - 关键日志样本
### 4.1 完整事件流样本(session: sess_3b943085816f466aa49e
- **首个 novel_delta**: `2026-07-21T14:39:07.363166030Z`
- **最后一个 novel_delta**: `2026-07-21T14:39:10.549778832Z`
- **novel_done**: `2026-07-21T14:39:10.558824365Z`
- **总耗时**: 约 3.2 秒
- **事件总数**: 约 50+ 个 novel_delta + 1 个 novel_done
### 4.2 服务错误日志
```
2026-07-21 22:45:15 [main] ERROR org.springframework.boot.SpringApplication -
Application run failed
java.lang.NullPointerException: Cannot invoke
"com.emotion.config.ShortNovelConfig.getConnectTimeout()" because "this.config" is null
at com.emotion.service.impl.ShortNovelServiceImpl.<init>(ShortNovelServiceImpl.java:54)
```
### 4.3 表结构摘要(t_epic_script
主键:`id (varchar(64))`
关键字段:`title``theme``plot_intro``plot_json``conversation_id`
时间字段:`create_time``update_time`
逻辑删除:`is_deleted (tinyint)`
---
**文档版本:** v1.0
**报告人:** AI Assistant
**下一步:** 紧急修复 + 用户手动验证
View File
+2 -2
View File
@@ -1,12 +1,12 @@
# 开发环境配置(本地开发调试)
VITE_APP_ENV=dev
# 本地环境
# 本地环境(本地不启动后端,禁用)
# VITE_API_BASE_URL=http://localhost:19089/api
# VITE_WS_URL=ws://localhost:19089/ws
# 直连后端服务(不经过 Nginx
# VITE_API_BASE_URL=http://101.200.208.45:19089/api
# VITE_WS_URL=ws://101.200.208.45:19089/ws
# 测试环境
# 测试环境(H5 走生产服务器后端,符合 CLAUDE.md 服务器端验收规则)
VITE_API_BASE_URL=https://lifescript.happylifeos.com/api
VITE_WS_URL=wss://lifescript.happylifeos.com/ws
VITE_DEBUG=true
@@ -0,0 +1,216 @@
<template>
<view class="clarification-card">
<view v-if="card.question" class="card-question">
<text>{{ card.question }}</text>
</view>
<view v-if="card.description" class="card-description">
<text>{{ card.description }}</text>
</view>
<view v-if="hasOptions" class="card-options">
<view
v-for="opt in card.options"
:key="opt.value"
class="option-item"
:class="{ active: isSelected(opt.value) }"
@click="toggleOption(opt)"
>
<view class="option-content">
<text class="option-label">{{ opt.label }}</text>
<text v-if="opt.description" class="option-desc">{{ opt.description }}</text>
</view>
<view v-if="isSelected(opt.value)" class="option-check"></view>
</view>
</view>
<view v-if="card.allow_custom || isTextInput" class="card-custom">
<textarea
v-model="customText"
:placeholder="card.input_placeholder || '请输入你的回答'"
class="custom-textarea"
:auto-height="true"
:show-confirm-bar="false"
maxlength="200"
/>
</view>
<view class="card-actions">
<view class="submit-btn" :class="{ disabled: !canSubmit }" @click="handleSubmit">
<text>提交</text>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
card: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['submit'])
const selectedValues = ref([])
const customText = ref('')
const isTextInput = computed(() => props.card.card_type === 'text_input')
const hasOptions = computed(() => Array.isArray(props.card.options) && props.card.options.length > 0)
const isSingle = computed(() => props.card.card_type === 'single_select')
const isMulti = computed(() => props.card.card_type === 'multi_select' || props.card.card_type === 'mixed')
const canSubmit = computed(() => {
// 纯文本输入卡片:只需 customText 非空
if (isTextInput.value) return customText.value.trim().length > 0
// 有选项的卡片:满足最小选择数,或(允许自定义且 customText 非空)
const minSel = props.card.min_selections || 1
if (selectedValues.value.length >= minSel) return true
if (props.card.allow_custom && customText.value.trim()) return true
return false
})
function isSelected(value) {
return selectedValues.value.includes(value)
}
function toggleOption(opt) {
const value = opt.value
// 修复:默认当作单选处理(当不是多选也不是文本输入时)
if (isMulti.value) {
// 多选逻辑
const idx = selectedValues.value.indexOf(value)
if (idx >= 0) {
selectedValues.value.splice(idx, 1)
} else {
const maxSel = props.card.max_selections || selectedValues.value.length + 1
if (selectedValues.value.length < maxSel) {
selectedValues.value.push(value)
}
}
} else if (!isTextInput.value) {
// 单选逻辑(默认行为,包括 card_type 未定义或未知的情况)
selectedValues.value = [value]
}
}
function handleSubmit() {
if (!canSubmit.value) return
let answer
if (selectedValues.value.length > 0) {
answer = selectedValues.value.join('、')
if (customText.value.trim()) {
answer = `${answer}${customText.value.trim()}`
}
} else {
answer = customText.value.trim()
}
emit('submit', answer)
}
</script>
<style scoped>
.clarification-card {
background: rgba(255, 255, 255, 0.08);
border-radius: 16rpx;
padding: 32rpx 28rpx;
margin: 24rpx 0;
}
.card-question {
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
margin-bottom: 16rpx;
line-height: 1.5;
}
.card-description {
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
margin-bottom: 24rpx;
line-height: 1.5;
}
.card-options {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 24rpx;
}
.option-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
background: rgba(255, 255, 255, 0.05);
border: 2rpx solid rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
}
.option-item.active {
background: rgba(8, 126, 139, 0.3);
border-color: #087e8b;
}
.option-content {
flex: 1;
display: flex;
flex-direction: column;
}
.option-label {
color: #ffffff;
font-size: 28rpx;
}
.option-desc {
color: rgba(255, 255, 255, 0.6);
font-size: 24rpx;
margin-top: 8rpx;
}
.option-check {
color: #087e8b;
font-size: 32rpx;
margin-left: 16rpx;
}
.card-custom {
margin-bottom: 24rpx;
}
.custom-textarea {
width: 100%;
min-height: 120rpx;
padding: 20rpx;
background: rgba(255, 255, 255, 0.05);
border-radius: 12rpx;
color: #ffffff;
font-size: 28rpx;
box-sizing: border-box;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
.submit-btn {
padding: 16rpx 48rpx;
background: #087e8b;
border-radius: 32rpx;
color: #ffffff;
font-size: 28rpx;
}
.submit-btn.disabled {
opacity: 0.5;
}
</style>
+46 -3
View File
@@ -1,10 +1,14 @@
<template>
<!-- 短消息气泡短消息 -->
<view v-if="isShortMessage" class="chat-bubble system">
<text>{{ content }}</text>
</view>
<!-- 长文本卡片story-card -->
<view v-else class="story-card" :class="{ collapsed }">
<view class="story-head">
<view class="story-title-wrap">
<text v-if="versionLabel" class="version-label">{{ versionLabel }}</text>
<text class="story-title">{{ title || '我的人生剧本' }}</text>
<view v-if="tags?.length" class="tag-row">
<text v-for="tag in tags" :key="tag" class="tag">{{ tag }}</text>
@@ -41,10 +45,14 @@
</view>
</view>
<!-- 按旧版布局统一按钮组 -->
<view class="result-actions">
<button class="action-btn" @click="$emit('change-direction')">换个方向</button>
<button class="action-btn" @click="$emit('not-like-me')">不像我</button>
<button class="action-btn" @click="$emit('continue')">继续生成</button>
<button v-if="canContinue" class="action-btn" @click="$emit('continue-script')">续写</button>
<button v-if="canRewrite" class="action-btn" @click="$emit('rewrite')">改写</button>
<button v-if="hasChildren" class="action-btn" @click="$emit('view-versions')">查看历史版本</button>
<button v-if="canDelete" class="action-btn danger" @click="$emit('delete-version')">删除版本</button>
<button class="action-btn primary" @click="$emit('play-tts')">
<text class="action-icon">{{ ttsIcon || '▶' }}</text>
<text>{{ ttsText || '播放' }}</text>
@@ -62,7 +70,13 @@ const props = defineProps({
contentLength: { type: Number, required: true },
isShortMessage: { type: Boolean, required: true },
ttsIcon: { type: String, default: '▶' },
ttsText: { type: String, default: '播放' }
ttsText: { type: String, default: '播放' },
// 新增
versionLabel: { type: String, default: '' },
hasChildren: { type: Boolean, default: false },
canDelete: { type: Boolean, default: false },
canRewrite: { type: Boolean, default: true },
canContinue: { type: Boolean, default: true }
})
defineEmits([
@@ -71,7 +85,12 @@ defineEmits([
'change-direction',
'not-like-me',
'continue',
'play-tts'
'play-tts',
// 新增
'rewrite',
'continue-script',
'view-versions',
'delete-version'
])
</script>
@@ -370,4 +389,28 @@ defineEmits([
.action-btn::after {
border: 0;
}
/* 新增样式 */
.version-label {
display: inline-block;
padding: 4rpx 16rpx;
margin-right: 12rpx;
border-radius: 12rpx;
background: rgba(140, 68, 242, 0.28);
color: #d6b8ff;
font-size: 22rpx;
font-weight: 700;
}
.script-actions {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
padding: 16rpx 0;
}
.action-btn.danger {
color: #ff8080;
border-color: rgba(255, 128, 128, 0.4);
}
</style>
+34 -8
View File
@@ -134,14 +134,41 @@ const isDescriptionCollapsed = ref(false)
const scrollTop = ref(0)
const currentScrollTop = ref(0)
// 从 localStorage 读取缓存的收藏状态
const getCachedFavoriteStatus = (id) => {
const cached = uni.getStorageSync(`favorite_event_${id}`)
return cached === 'true'
}
// 缓存收藏状态到 localStorage
const setCachedFavoriteStatus = (id, status) => {
uni.setStorageSync(`favorite_event_${id}`, status ? 'true' : 'false')
}
onMounted(async () => {
const info = uni.getWindowInfo()
safeAreaBottom.value = info.safeAreaInsets?.bottom || 0
const pages = getCurrentPages()
eventId.value = pages[pages.length - 1]?.options?.id || ''
// 先从缓存读取收藏状态,避免闪烁
if (eventId.value) {
isFavorite.value = getCachedFavoriteStatus(eventId.value)
}
cachedEvent.value = uni.getStorageSync('current_life_event') || null
if (!store.events?.length) await store.fetchEvents()
isFavorite.value = Boolean(uni.getStorageSync(`event_favorite_${eventId.value}`))
// 异步校验真实收藏状态并更新缓存
if (eventId.value) {
const result = await store.checkEventFavorite(eventId.value)
if (result.success) {
const realStatus = result.data
isFavorite.value = realStatus
setCachedFavoriteStatus(eventId.value, realStatus)
}
}
analytics.trackPageView(pagePath, {
life_event_id: eventId.value,
tag_count: Array.isArray(displayEvent.value.tags) ? displayEvent.value.tags.length : 0
@@ -305,20 +332,19 @@ const editEvent = () => {
const toggleFavorite = async () => {
if (!eventId.value) return
const next = !isFavorite.value
const result = await store.favoriteEvent({ id: eventId.value, favorite: next })
const result = await store.toggleEventFavorite(eventId.value)
if (!result.success) {
uni.showToast({ title: result.error || '收藏失败', icon: 'none' })
return
}
isFavorite.value = next
if (next) uni.setStorageSync(`event_favorite_${eventId.value}`, '1')
else uni.removeStorageSync(`event_favorite_${eventId.value}`)
const newStatus = result.data?.isFavorited ?? false
isFavorite.value = newStatus
setCachedFavoriteStatus(eventId.value, newStatus)
analytics.track('life_event_favorite', {
life_event_id: eventId.value,
favorite: next
favorite: newStatus
}, { eventType: 'life_event', pagePath })
uni.showToast({ title: next ? '已收藏' : '已取消收藏', icon: 'success' })
uni.showToast({ title: newStatus ? '已收藏' : '已取消收藏', icon: 'success' })
}
const chatEvent = async () => {
+6 -4
View File
@@ -108,7 +108,7 @@ const activeMethod = ref('')
const countdown = ref(60)
const isCountingDown = ref(false)
const loginConfig = ref({ wechatLoginEnabled: true, smsLoginEnabled: false })
const loginConfig = ref({ wechatLoginEnabled: true, smsLoginEnabled: true })
onMounted(async () => {
const windowInfo = uni.getWindowInfo()
@@ -120,10 +120,12 @@ onMounted(async () => {
const res = await getLoginConfig()
if (res.data) {
loginConfig.value = res.data
// 临时开启手机号验证码登录,便于验收
loginConfig.value.smsLoginEnabled = true
}
} catch (e) {
// 接口失败时默认显示微信登录
loginConfig.value = { wechatLoginEnabled: true, smsLoginEnabled: false }
// 接口失败时默认显示手机号登录
loginConfig.value = { wechatLoginEnabled: true, smsLoginEnabled: true }
}
})
@@ -144,7 +146,7 @@ const handleGetCode = async () => {
uni.showToast({ title: '验证码已发送', icon: 'success' })
startCountdown()
} catch (error) {
uni.showToast({ title: '验证码已发送(模拟: 888888', icon: 'none' })
uni.showToast({ title: '验证码已发送(模拟: 123456', icon: 'none' })
startCountdown()
}
}
+510 -171
View File
@@ -10,42 +10,94 @@
</view>
<scroll-view class="scroll" scroll-y :show-scrollbar="false">
<view class="hero-card kos-card">
<text class="eyebrow">PARALLEL LIFE</text>
<text class="script-title">{{ script?.title || '未命名剧本' }}</text>
<text class="script-summary">{{ script?.summary || '命运正在整理章节。' }}</text>
<view class="stats">
<view class="stat">
<text class="stat-value">{{ script?.style || '爽文' }}</text>
<text class="stat-label">风格</text>
<view class="detail-flow">
<view
v-for="msg in resultMessages"
:key="msg.id"
>
<!-- 用户心愿气泡 -->
<view v-if="msg.role === 'user' && msg.kind === 'text'" class="chat-bubble user">
<text>{{ msg.content }}</text>
</view>
<view class="stat">
<text class="stat-value">{{ lengthText }}</text>
<text class="stat-label">篇幅</text>
<!-- 澄清问答卡片只读已回答状态 -->
<view v-else-if="msg.kind === 'card'" class="chat-bubble system">
<view class="card-answered">
<text v-if="msg.card?.question" class="card-question-text">{{ msg.card.question }}</text>
<!-- 用户回答中文 label通过 options value 映射回 label 显示 -->
<view class="card-answer-row">
<text class="card-answer-prefix">已回答</text>
<text class="card-answer-label">{{ getAnswerLabel(msg) }}</text>
</view>
<!-- 选项列表展示所有可选选项已选的高亮显示 -->
<view v-if="Array.isArray(msg.card?.options) && msg.card.options.length" class="card-options-list">
<view
v-for="opt in msg.card.options"
:key="opt.value"
class="card-option-item"
:class="{ selected: opt.value === (msg.selectedValue || msg.answer) }"
>
<text class="card-option-label">{{ opt.label || opt.value }}</text>
<text v-if="opt.description" class="card-option-desc">{{ opt.description }}</text>
<text v-if="opt.value === (msg.selectedValue || msg.answer)" class="card-option-mark"></text>
</view>
</view>
</view>
</view>
<view class="stat">
<text class="stat-value">{{ script?.wordCount || 0 }}</text>
<text class="stat-label">字数</text>
<!-- 大纲beats 列表只读 -->
<view v-else-if="msg.kind === 'outline'" class="chat-bubble system">
<view v-if="msg.outline">
<view v-if="msg.outline.title" class="outline-title">
<text>{{ msg.outline.title }}</text>
</view>
<view v-if="msg.outline.logline" class="outline-logline">
<text>{{ msg.outline.logline }}</text>
</view>
<view v-if="Array.isArray(msg.outline.beats)" class="outline-beats">
<view v-for="(beat, idx) in msg.outline.beats" :key="idx" class="beat-item">
<text class="beat-order">{{ beat.order || idx + 1 }}</text>
<view class="beat-content">
<text class="beat-title">{{ beat.title || '' }}</text>
<text v-if="beat.summary" class="beat-summary">{{ beat.summary }}</text>
</view>
</view>
</view>
<view v-if="msg.outline.ending" class="outline-ending">
<text class="ending-label">结局</text>
<text class="ending-text">{{ msg.outline.ending }}</text>
</view>
</view>
</view>
<!-- 小说正文Markdown 渲染 -->
<view v-else-if="msg.kind === 'novel'" class="chat-bubble system novel-bubble">
<Markdown :content="getMessageDisplayContent(msg)" />
<!-- 折叠/展开按钮内容超过预览长度时显示 -->
<view v-if="msg.content && msg.content.length > ASSISTANT_MESSAGE_PREVIEW_LENGTH" class="collapse-toggle-row" @click="toggleMessageCollapse(msg)">
<text class="collapse-toggle-text">{{ isMessageCollapsed(msg) ? '展开全文' : '收起全文' }}</text>
<view class="collapse-chevron" :class="{ down: isMessageCollapsed(msg) }">
<view></view>
<view></view>
</view>
</view>
<!-- 操作按钮组 -->
<view class="message-actions">
<button class="action-btn" @click="copyMessageContent(msg)">
<text>复制</text>
</button>
<button class="action-btn primary" @click="playMessageTts(msg)">
<text class="action-icon">{{ ttsPlayer.playing.value ? 'Ⅱ' : '▶' }}</text>
<text>{{ ttsPlayer.playing.value ? '暂停' : '播放' }}</text>
</button>
</view>
</view>
</view>
</view>
<view class="tabs kos-card">
<view class="tab" :class="{ active: activeTab === 'content' }" @click="activeTab = 'content'">正文</view>
<view class="tab" :class="{ active: activeTab === 'outline' }" @click="activeTab = 'outline'">大纲</view>
</view>
<view v-if="activeTab === 'content'" class="article-card kos-card">
<Markdown :content="fullContent" />
</view>
<view v-else class="outline-list">
<view v-for="(item, index) in outline" :key="index" class="outline-card kos-card">
<view class="outline-node">{{ index + 1 }}</view>
<view class="outline-body">
<text class="outline-title">{{ item.title }}</text>
<text class="outline-text">{{ item.text }}</text>
</view>
<view v-if="resultMessages.length === 0" class="empty-flow">
<text>暂无剧本内容</text>
</view>
</view>
</scroll-view>
@@ -68,16 +120,20 @@ import Markdown from '../../components/Markdown.vue'
import analytics from '../../services/analytics.js'
import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
import { listMessagesByConversation } from '../../services/scriptChat.js'
const store = useAppStore()
const activeTab = ref('content')
const scriptId = ref('')
const script = ref(null)
const resultMessages = ref([])
const collapsedMessageIds = ref({})
const pagePath = '/pages/main/ScriptDetailView'
const ttsPlayer = useTtsPlayer({ pagePath })
const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 10 })
const fullContent = computed(() => script.value?.content || '暂无正文内容。')
// 小说正文折叠时的预览长度(字符)
const ASSISTANT_MESSAGE_PREVIEW_LENGTH = 120
const lengthText = computed(() => {
const map = { short: '短篇', medium: '中篇', long: '长篇' }
return map[script.value?.length] || script.value?.length || '中篇'
@@ -93,39 +149,187 @@ const detailTtsIcon = computed(() => {
return ttsPlayer.playing.value ? 'Ⅱ' : '▶'
})
const outline = computed(() => {
const text = fullContent.value
const parts = text.split(/\n{2,}/).filter(Boolean)
if (parts.length > 1) {
return parts.slice(0, 8).map((part, index) => {
const clean = part.replace(/[#>*_`]/g, '').trim()
const lines = clean.split('\n').filter(Boolean)
return {
title: lines[0]?.replace(/[【】]/g, '') || `章节 ${index + 1}`,
text: lines.slice(1).join(' ').slice(0, 120) || clean.slice(0, 120)
}
/**
* 判断消息是否处于折叠状态
*/
const isMessageCollapsed = (message) => {
return Boolean(collapsedMessageIds.value[message.id])
}
/**
* 获取消息显示内容(折叠时截断到预览长度)
*/
const getMessageDisplayContent = (message) => {
const content = String(message?.content || '')
if (!isMessageCollapsed(message)) return content
if (content.length <= ASSISTANT_MESSAGE_PREVIEW_LENGTH) return content
return `${content.slice(0, ASSISTANT_MESSAGE_PREVIEW_LENGTH)}...`
}
/**
* 切换消息折叠/展开状态
*/
const toggleMessageCollapse = (message) => {
collapsedMessageIds.value = {
...collapsedMessageIds.value,
[message.id]: !isMessageCollapsed(message)
}
analytics.track('script_message_collapse_toggle', {
message_id: message?.id || '',
collapsed: collapsedMessageIds.value[message.id]
}, { eventType: 'script', pagePath })
}
/**
* 复制消息内容到剪贴板
*/
const copyMessageContent = (message) => {
const content = String(message?.content || '')
if (!content.trim()) {
uni.showToast({ title: '暂无可复制内容', icon: 'none' })
return
}
uni.setClipboardData({
data: content,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
}
})
analytics.track('script_message_copy_click', {
message_id: message?.id || '',
content_length: content.length
}, { eventType: 'script', pagePath })
}
/**
* 播放消息 TTS 音频(播放整个剧本的音频)
*/
const playMessageTts = (message) => {
const scriptIdValue = script.value?.id || ''
ttsPlayer.playSource(scriptIdValue)
analytics.track('script_message_tts_click', {
message_id: message?.id || '',
script_id: scriptIdValue
}, { eventType: 'script', pagePath })
}
/**
* 从后端 message 列表重建统一对话消息流
* 复刻 ScriptView 生成时的 chat-bubble 展示结构
*/
const rebuildResultMessages = (scriptData, messages) => {
const result = []
// 1. 用户心愿作为首条 user 消息(如果 messages 中没有 user 心愿消息则用 theme 补)
const hasUserWishInMessages = messages.some(m => m.sender === 'user' && m.type === 'chat')
if (!hasUserWishInMessages && scriptData?.theme) {
result.push({
id: `wish-${scriptData.id}`,
role: 'user',
kind: 'text',
content: scriptData.theme
})
}
return [
{ title: '起点', text: script.value?.theme || '从真实的人生经验出发。' },
{ title: '转折', text: '关键机会出现,旧有困境开始松动。' },
{ title: '高光', text: '主角完成一次真正的选择,并看见新的自我。' },
{ title: '回响', text: '剧本落回现实,转化为可执行路径。' }
]
})
// 2. 澄清问答按 messageOrder 升序配对(DB 中 answer 可能先于 question 存入)
const clarificationQuestions = messages
.filter(m => m.type === 'clarification_question')
.sort((a, b) => (a.messageOrder || 0) - (b.messageOrder || 0))
const clarificationAnswers = messages
.filter(m => m.type === 'clarification_answer')
.sort((a, b) => (a.messageOrder || 0) - (b.messageOrder || 0))
const loadScript = async () => {
if (!scriptId.value) return
ttsPlayer.reset()
script.value = store.getScriptById(scriptId.value)
if (!script.value) {
await store.fetchScripts()
script.value = store.getScriptById(scriptId.value)
// 为每个 question 配对它之后第一个未分配的 answer(按 messageOrder 升序)
const answerAssigned = new Set()
const questionAnswerMap = new Map()
const unmatchedAnswers = []
clarificationQuestions.forEach(q => {
const matched = clarificationAnswers.find(a =>
!answerAssigned.has(a.id) && (a.messageOrder || 0) > (q.messageOrder || 0)
)
if (matched) {
answerAssigned.add(matched.id)
questionAnswerMap.set(q.id, matched)
}
})
clarificationAnswers.forEach(a => {
if (!answerAssigned.has(a.id)) unmatchedAnswers.push(a)
})
// 为每个 question 生成 card 消息(含配对答案)
const cardMessagesByQuestionId = new Map()
clarificationQuestions.forEach(q => {
let card = {}
try { card = JSON.parse(q.content || '{}') } catch (e) { card = {} }
const answer = questionAnswerMap.get(q.id)
cardMessagesByQuestionId.set(q.id, {
id: q.id,
role: 'assistant',
kind: 'card',
card,
answer: answer?.content || '',
selectedValue: answer?.content || '',
submitted: true
})
})
// 3. 按 messageOrder 顺序遍历,构建统一消息流
messages.forEach(m => {
if (m.type === 'clarification_question') {
const cardMsg = cardMessagesByQuestionId.get(m.id)
if (cardMsg) result.push(cardMsg)
} else if (m.type === 'clarification_answer') {
// 已配对的 answer 由 question 那一步负责渲染;这里跳过
if (answerAssigned.has(m.id)) return
// 未配对的 answer(如 messageOrder 在所有 question 之前的预填充)不展示
// 原因:没有对应 question 的 answer 无法正确渲染(用户看不到问题,只显示答案会造成困惑)
return
} else if (m.type === 'outline') {
let outline = {}
try { outline = JSON.parse(m.content || '{}') } catch (e) { outline = {} }
result.push({ id: m.id, role: 'assistant', kind: 'outline', outline })
} else if (m.type === 'script') {
result.push({ id: m.id, role: 'assistant', kind: 'novel', content: m.content || '' })
} else if (m.type === 'system') {
// 跳过系统欢迎消息,不展示
} else if (m.type === 'chat' && m.sender === 'user') {
result.push({ id: m.id, role: 'user', kind: 'text', content: m.content || '' })
}
})
// 调整顺序:用户心愿应该在最前面(DB 中 messageOrder 可能靠后,但逻辑上是第一条)
const userWishIndex = result.findIndex(m => m.role === 'user' && m.kind === 'text')
if (userWishIndex > 0) {
const [userWish] = result.splice(userWishIndex, 1)
result.unshift(userWish)
}
if (script.value?.id) {
ttsPlayer.prewarmSource(script.value.id)
// 5. 如果 messages 中没有 script 类型消息,fallback 到 plotJson.fullContent
const hasNovel = result.some(m => m.kind === 'novel')
if (!hasNovel && scriptData?.plotJson?.fullContent) {
result.push({
id: `fallback-novel-${scriptData.id}`,
role: 'assistant',
kind: 'novel',
content: scriptData.plotJson.fullContent
})
}
return result
}
/**
* 把已回答卡片的 value(如 "family")映射为对应的中文 label(如 "家人"
* 若选项中找不到匹配的 value,则原样返回 answer(兜底)
* 若 msg.card 不存在则返回 "未回答"
*/
const getAnswerLabel = (msg) => {
if (!msg || !msg.card) return '未回答'
const raw = msg.selectedValue || msg.answer || ''
if (!raw) return '未回答'
const options = Array.isArray(msg.card.options) ? msg.card.options : []
const match = options.find(opt => opt && opt.value === raw)
return match?.label || raw
}
const continueCurrent = () => {
@@ -153,9 +357,32 @@ const goBack = () => {
}
onMounted(async () => {
// scriptId 从路由参数获取
const pages = getCurrentPages()
scriptId.value = pages[pages.length - 1]?.options?.id || ''
await loadScript()
const currentPage = pages[pages.length - 1]
scriptId.value = currentPage?.options?.id || currentPage?.$page?.options?.id || ''
// 1. 加载 script 数据
await store.fetchScripts()
script.value = store.getScriptById(scriptId.value)
// 2. 加载 conversation messages
const conversationId = script.value?.conversationId
if (conversationId) {
try {
const res = await listMessagesByConversation({ conversationId, includeVersions: false })
const messages = Array.isArray(res?.data) ? res.data : []
resultMessages.value = rebuildResultMessages(script.value, messages)
} catch (e) {
console.error('[ScriptDetailView] 加载 messages 失败:', e)
// 降级:仅展示心愿 + 正文
resultMessages.value = rebuildResultMessages(script.value, [])
}
} else {
// 无 conversationId,降级展示
resultMessages.value = rebuildResultMessages(script.value, [])
}
analytics.trackPageView(pagePath, { script_id: scriptId.value })
analytics.track('script_detail_view', {
script_id: scriptId.value,
@@ -242,144 +469,256 @@ onUnmounted(() => {
padding: 0 30rpx;
}
.hero-card {
border-radius: 32rpx;
padding: 36rpx 30rpx;
/* chat-bubble 流程视图样式 */
.detail-flow {
padding: 20rpx 0 160rpx;
}
.eyebrow {
display: block;
color: #c186ff;
font-size: 18rpx;
letter-spacing: 6rpx;
.chat-bubble {
margin-bottom: 24rpx;
padding: 24rpx;
border-radius: 24rpx;
}
.script-title {
display: block;
margin-top: 14rpx;
color: #fff;
font-size: 44rpx;
line-height: 1.18;
font-weight: 900;
.chat-bubble.user {
background: rgba(168, 85, 247, 0.12);
align-self: flex-end;
margin-left: 80rpx;
}
.script-summary {
display: block;
margin-top: 18rpx;
color: rgba(226, 216, 246, 0.7);
font-size: 25rpx;
line-height: 1.62;
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
margin-top: 28rpx;
}
.stat {
padding: 18rpx 10rpx;
border-radius: 20rpx;
text-align: center;
.chat-bubble.system {
background: rgba(255, 255, 255, 0.06);
margin-right: 40rpx;
}
.stat-value,
.stat-label {
display: block;
.card-answered {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.stat-value {
.card-question-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.card-answer-row {
display: flex;
align-items: baseline;
gap: 8rpx;
margin-bottom: 4rpx;
}
.card-answer-prefix {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.6);
}
.card-answer-label {
font-size: 28rpx;
color: rgba(193, 134, 255, 0.95);
font-weight: 600;
}
.card-options-list {
display: flex;
flex-direction: column;
gap: 10rpx;
margin-top: 8rpx;
}
.card-option-item {
position: relative;
display: flex;
flex-direction: column;
gap: 4rpx;
padding: 14rpx 18rpx;
border-radius: 14rpx;
background: rgba(255, 255, 255, 0.04);
border: 1rpx solid rgba(255, 255, 255, 0.08);
}
.card-option-item.selected {
background: rgba(168, 85, 247, 0.16);
border-color: rgba(168, 85, 247, 0.45);
}
.card-option-label {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.92);
font-weight: 500;
}
.card-option-item.selected .card-option-label {
color: rgba(220, 195, 255, 1);
}
.card-option-desc {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.55);
line-height: 1.5;
}
.card-option-mark {
position: absolute;
right: 14rpx;
top: 50%;
transform: translateY(-50%);
font-size: 30rpx;
color: rgba(220, 195, 255, 1);
font-weight: 700;
}
.outline-title {
font-size: 32rpx;
font-weight: 600;
color: #fff;
margin-bottom: 12rpx;
}
.outline-logline {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 16rpx;
}
.outline-beats {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.beat-item {
display: flex;
gap: 16rpx;
}
.beat-order {
font-size: 24rpx;
font-weight: 800;
color: rgba(193, 134, 255, 0.88);
width: 40rpx;
}
.beat-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.beat-title {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
}
.beat-summary {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
}
.outline-ending {
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.1);
}
.ending-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
margin-right: 12rpx;
}
.ending-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
}
.novel-bubble {
padding: 32rpx;
}
.stat-label {
margin-top: 6rpx;
color: rgba(219, 207, 243, 0.58);
font-size: 19rpx;
}
.tabs {
height: 76rpx;
margin-top: 24rpx;
border-radius: 999rpx;
padding: 6rpx;
display: grid;
grid-template-columns: 1fr 1fr;
}
.tab {
border-radius: 999rpx;
/* 折叠/展开按钮行 */
.collapse-toggle-row {
display: flex;
align-items: center;
justify-content: center;
color: rgba(224, 214, 243, 0.64);
gap: 12rpx;
margin-top: 20rpx;
padding: 16rpx 0;
border-radius: 999rpx;
background: rgba(88, 28, 135, 0.12);
border: 1rpx solid rgba(192, 132, 252, 0.28);
color: rgba(246, 230, 255, 0.96);
font-size: 26rpx;
font-weight: 700;
}
.tab.active {
color: #fff;
background: linear-gradient(135deg, #a63cff, #6530ff);
box-shadow: 0 0 26rpx rgba(162, 71, 255, 0.48);
.collapse-toggle-row:active {
transform: scale(0.98);
opacity: 0.92;
}
.article-card {
margin: 24rpx 0 34rpx;
border-radius: 30rpx;
padding: 30rpx;
.collapse-toggle-text {
line-height: 1;
}
.outline-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin: 24rpx 0 34rpx;
}
.outline-card {
border-radius: 26rpx;
padding: 26rpx;
display: flex;
gap: 20rpx;
}
.outline-node {
width: 48rpx;
height: 48rpx;
/* 折叠箭头图标 */
.collapse-chevron {
position: relative;
width: 20rpx;
height: 16rpx;
flex-shrink: 0;
border-radius: 50%;
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;
}
/* 操作按钮组 */
.message-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 20rpx;
}
.action-btn {
height: 72rpx;
min-height: 72rpx;
padding: 0 8rpx;
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
color: #e8ccff;
font-size: 26rpx;
font-weight: 700;
line-height: 1.15;
text-align: center;
white-space: normal;
box-sizing: border-box;
background: rgba(88, 28, 135, 0.18);
border: 1rpx solid rgba(192, 132, 252, 0.35);
}
.action-btn.primary {
color: #fff;
font-size: 22rpx;
font-weight: 900;
background: linear-gradient(135deg, #a855ff, #4f46e5);
box-shadow: 0 0 22rpx rgba(168, 85, 255, 0.48);
background: linear-gradient(145deg, #8c44f2, #5f1db8);
}
.outline-body {
flex: 1;
min-width: 0;
}
.outline-title {
display: block;
color: #fff;
font-size: 27rpx;
font-weight: 900;
}
.outline-text {
display: block;
margin-top: 10rpx;
color: rgba(223, 211, 245, 0.7);
.action-icon {
margin-right: 8rpx;
font-size: 24rpx;
line-height: 1.6;
font-weight: 900;
line-height: 1;
}
.action-btn::after {
border: 0;
}
.empty-flow {
padding: 80rpx 0;
text-align: center;
color: rgba(255, 255, 255, 0.4);
font-size: 28rpx;
}
.bottom-actions {
+125 -91
View File
@@ -33,23 +33,10 @@
</view>
</view>
<view class="filter-bar">
<scroll-view class="status-scroll" scroll-x :show-scrollbar="false">
<view class="status-row">
<text
v-for="filter in statusFilters"
:key="filter.value"
class="status-chip"
:class="{ active: activeStatus === filter.value }"
@click="activeStatus = filter.value"
>{{ filter.label }}</text>
</view>
</scroll-view>
<view class="sort-tools">
<text class="sort-text" @click="toggleSort">{{ sortLabel }}</text>
<view class="grid-icon" :class="{ active: viewMode === 'grid' }" @click="toggleViewMode">
<view v-for="i in 4" :key="i"></view>
</view>
<view class="sort-bar">
<text class="sort-text" @click="toggleSort">{{ sortLabel }}</text>
<view class="grid-icon" :class="{ active: viewMode === 'grid' }" @click="toggleViewMode">
<view v-for="i in 4" :key="i"></view>
</view>
</view>
@@ -99,6 +86,10 @@
<text v-for="tag in getTags(script)" :key="tag" class="tag">{{ tag }}</text>
</view>
<view v-if="getOutlinePreview(script)" class="outline-preview-row">
<text class="outline-preview-text">{{ getOutlinePreview(script) }}</text>
</view>
<text class="summary">{{ script.summary || script.content || '一段正在生成中的平行人生剧本。' }}</text>
<view class="meta-row">
@@ -147,31 +138,32 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, ref, watch, onMounted } from 'vue'
import { useAppStore } from '../../stores/app.js'
import { toggleFavoriteScript, checkFavoriteScript } from '../../services/epicScript.js'
const store = useAppStore()
const activeType = ref('long')
const activeStatus = ref('all')
const activeType = ref('all')
const keyword = ref('')
const sortMode = ref('updated')
const viewMode = ref('list')
const localFavorites = ref(uni.getStorageSync('script_favorites') || {})
const favoriteStatus = ref({})
const activeMenuId = ref('')
const deleteTarget = ref(null)
const deletingScript = ref(false)
const typeTabs = [
{ label: '长篇', value: 'long' },
{ label: '短篇', value: 'short' },
{ label: '风格', value: 'style' }
]
// 每次打开历史列表(组件挂载)时主动拉取最新剧本,避免显示 store 旧缓存
// 解决:生成完小说后打开历史列表看不到最新记录的问题
onMounted(() => {
store.fetchScripts()
})
const statusFilters = [
// 固定 5 个分类标签:全部 / 短篇 / 中篇 / 长篇 / 收藏夹
const typeTabs = [
{ label: '全部', value: 'all' },
{ label: '进行中', value: 'progress' },
{ label: '已完成', value: 'done' },
{ label: '草稿箱', value: 'draft' },
{ label: '短篇', value: 'short' },
{ label: '中篇', value: 'medium' },
{ label: '长篇', value: 'long' },
{ label: '收藏夹', value: 'favorite' }
]
@@ -179,15 +171,14 @@ const scripts = computed(() => store.scripts || [])
const visibleScripts = computed(() => {
const filtered = scripts.value.filter(script => {
const status = getStatus(script)
if (keyword.value) {
const haystack = [script.title, script.summary, script.content, script.style, ...(script.tags || [])].join(' ')
if (!haystack.includes(keyword.value)) return false
}
if (activeStatus.value === 'favorite') return isFavorite(script)
if (activeStatus.value !== 'all' && status !== activeStatus.value) return false
if (activeType.value === 'short') return script.length === 'short'
if (activeType.value === 'long') return script.length !== 'short'
// 收藏夹分类:按收藏状态筛选
if (activeType.value === 'favorite') return isFavorite(script)
// 全部:不过滤;其他:精确匹配 length 字段
if (activeType.value !== 'all') return script.length === activeType.value
return true
})
return [...filtered].sort((a, b) => {
@@ -197,6 +188,26 @@ const visibleScripts = computed(() => {
})
})
// 列表加载后批量检查收藏状态
const loadFavoriteStatus = async () => {
const ids = scripts.value.map(s => String(s.id)).filter(id => !id.startsWith('demo-'))
if (!ids.length) return
const status = {}
await Promise.all(ids.map(async (id) => {
try {
status[id] = await checkFavoriteScript(id)
} catch {
status[id] = false
}
}))
favoriteStatus.value = status
}
// 监听 scripts 变化时重新加载收藏状态
watch(scripts, () => {
loadFavoriteStatus()
}, { immediate: true })
const sortLabel = computed(() => {
const map = { updated: '最近更新⌄', words: '字数最多⌄', progress: '进度最高⌄' }
return map[sortMode.value] || '最近更新⌄'
@@ -215,7 +226,8 @@ const getStatusLabel = (script) => {
}
const getLengthLabel = (length) => {
return length === 'short' ? '篇' : '长篇'
const map = { short: '短篇', medium: '篇', long: '长篇' }
return map[length] || '长篇'
}
const getTags = (script) => {
@@ -223,7 +235,18 @@ const getTags = (script) => {
return []
}
const getChapterCount = (script) => script.chapterCount || script.chapters || 0
const getChapterCount = (script) => {
// 优先从 beats 数组长度获取(Task 1-5 部署后新剧本结构)
const beats = script?.plotJson?.stages?.outline?.beats
if (Array.isArray(beats) && beats.length > 0) return beats.length
// Fallback:根据 length 字段估算(老剧本无 beats 数据)
const length = script?.length
if (length === 'short') return 1
if (length === 'medium') return 2
if (length === 'long') return 4
return 1
}
const getWordCount = (script) => {
const count = Number(script.wordCount || 0)
@@ -241,8 +264,27 @@ const getProgress = (script) => Math.max(0, Math.min(99, Number(script.progress
const getInitial = (script) => (script.title || '剧').slice(0, 1)
/**
* 获取大纲预览文本(前 2 章标题)
* 数据来源按优先级探测:
* 1. plotJson.stages.outline.beatsTask 1-5 部署后新生成的小说)
* 2. plotJson.fullPayload.outline.beats(兼容老剧本可能的 fullPayload 结构)
* 3. 直接 plotJson.outline.beats(兜底)
*/
const getOutlinePreview = (script) => {
const beats = script?.plotJson?.stages?.outline?.beats
|| script?.plotJson?.fullPayload?.outline?.beats
|| script?.plotJson?.outline?.beats
if (!Array.isArray(beats) || beats.length === 0) return ''
const parts = beats.slice(0, 2).map((beat, i) => {
const title = beat.title || ''
return `${i + 1}章:${title}`
})
return `📋 ${parts.join(' · ')}`
}
const isFavorite = (script) => {
return Boolean(script.isFavorite || script.favorite || localFavorites.value[String(script.id)])
return Boolean(favoriteStatus.value[String(script.id)])
}
const openScriptChat = (script) => {
@@ -289,10 +331,10 @@ const openMoreMenu = () => {
itemList: ['清空搜索', '只看收藏', '查看全部'],
success: ({ tapIndex }) => {
if (tapIndex === 0) keyword.value = ''
if (tapIndex === 1) activeStatus.value = 'favorite'
if (tapIndex === 1) activeType.value = 'favorite'
if (tapIndex === 2) {
keyword.value = ''
activeStatus.value = 'all'
activeType.value = 'all'
}
}
})
@@ -316,15 +358,32 @@ const toggleScriptMenu = (script) => {
activeMenuId.value = activeMenuId.value === id ? '' : id
}
const toggleFavorite = (script) => {
const favorite = isFavorite(script)
const next = { ...localFavorites.value }
if (favorite) delete next[String(script.id)]
else next[String(script.id)] = true
localFavorites.value = next
uni.setStorageSync('script_favorites', next)
const toggleFavorite = async (script) => {
const wasFav = isFavorite(script)
// 乐观更新:先改内存状态
const next = { ...favoriteStatus.value }
if (wasFav) {
delete next[String(script.id)]
} else {
next[String(script.id)] = true
}
favoriteStatus.value = next
closeScriptMenu()
uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' })
try {
const result = await toggleFavoriteScript(script.id)
uni.showToast({ title: result.isFavorited ? '已收藏' : '已取消收藏', icon: 'success' })
} catch (error) {
// 回滚乐观更新
const restored = { ...favoriteStatus.value }
if (wasFav) {
restored[String(script.id)] = true
} else {
delete restored[String(script.id)]
}
favoriteStatus.value = restored
uni.showToast({ title: error?.message || '操作失败', icon: 'none' })
}
}
const continueScript = (script) => {
@@ -363,10 +422,9 @@ const confirmDeleteScript = async () => {
return
}
const next = { ...localFavorites.value }
const next = { ...favoriteStatus.value }
delete next[id]
localFavorites.value = next
uni.setStorageSync('script_favorites', next)
favoriteStatus.value = next
const conversationId = deleteTarget.value.conversationId || deleteTarget.value.plotJson?.conversationId
if (conversationId) uni.removeStorageSync(`script_conversation_history_${conversationId}`)
uni.removeStorageSync(`script_chat_history_${id}`)
@@ -390,8 +448,7 @@ const confirmDeleteScript = async () => {
.back-title,
.head-actions,
.type-tabs,
.filter-bar,
.sort-tools,
.sort-bar,
.card-top,
.title-wrap,
.right-state,
@@ -520,47 +577,11 @@ const confirmDeleteScript = async () => {
line-height: 1;
}
.filter-bar {
.sort-bar {
justify-content: flex-end;
gap: 14rpx;
}
.status-scroll {
flex: 1;
min-width: 0;
white-space: nowrap;
}
.status-row {
display: inline-flex;
gap: 16rpx;
}
.status-chip {
height: 52rpx;
min-width: 88rpx;
padding: 0 24rpx;
border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(224, 214, 243, 0.78);
font-size: 23rpx;
border: 1rpx solid rgba(151, 111, 255, 0.42);
background: rgba(255, 255, 255, 0.02);
}
.status-chip.active {
color: #fff;
border-color: rgba(206, 82, 255, 0.92);
background: rgba(130, 48, 220, 0.42);
box-shadow: 0 0 18rpx rgba(168, 67, 255, 0.46);
}
.sort-tools {
gap: 14rpx;
flex-shrink: 0;
}
.sort-text {
color: #c99fff;
font-size: 23rpx;
@@ -808,6 +829,19 @@ const confirmDeleteScript = async () => {
background: rgba(149, 55, 255, 0.2);
}
.outline-preview-row {
margin-top: 10rpx;
padding: 10rpx 14rpx;
border-radius: 14rpx;
background: rgba(168, 85, 247, 0.08);
border: 1rpx solid rgba(168, 85, 247, 0.16);
}
.outline-preview-text {
font-size: 22rpx;
color: rgba(193, 134, 255, 0.88);
line-height: 1.4;
}
.summary {
display: -webkit-box;
margin-top: 14rpx;
File diff suppressed because it is too large Load Diff
+43 -4
View File
@@ -148,7 +148,9 @@ export const transformToFrontendFormat = (backendData) => {
plotJson,
isSelected,
createTime,
updateTime
updateTime,
conversationId,
currentVersionMessageId
} = backendData
let content = ''
@@ -182,11 +184,12 @@ export const transformToFrontendFormat = (backendData) => {
date: createTime ? String(createTime).slice(0, 10) : new Date().toLocaleDateString(),
mode: plotJson?.mode || 'custom',
prompt: plotJson?.prompt || '',
conversationId: plotJson?.conversationId || '',
conversationId: conversationId || plotJson?.conversationId || '',
parentScriptId: plotJson?.parentScriptId || '',
revisionIndex: plotJson?.currentRevisionIndex || plotJson?.revisionIndex || 0,
revisionOf: plotJson?.revisionOf || '',
wordCount: content ? content.length : 0
wordCount: content ? content.length : 0,
currentVersionMessageId: currentVersionMessageId || ''
}
}
@@ -195,6 +198,39 @@ export const transformListToFrontend = (backendList) => {
return backendList.map(transformToFrontendFormat)
}
/**
* 切换剧本收藏状态(收藏/取消收藏)
* @param {string} scriptId 剧本ID
* @returns {Promise<{success: boolean, isFavorited: boolean}>}
*/
export const toggleFavoriteScript = async (scriptId) => {
const res = await post('/epicScript/favorite/toggle', { scriptId })
return {
success: true,
isFavorited: res.data?.isFavorited ?? false,
favoriteTime: res.data?.favoriteTime ?? null
}
}
/**
* 获取收藏剧本列表
* @param {number} current 当前页
* @param {number} size 每页大小
*/
export const getFavoriteScriptPage = async (current = 1, size = 10) => {
return get('/epicScript/favorite/page', { current, size })
}
/**
* 检查剧本是否已收藏
* @param {string} scriptId 剧本ID
* @returns {Promise<boolean>}
*/
export const checkFavoriteScript = async (scriptId) => {
const res = await get('/epicScript/favorite/check', { scriptId })
return res.data?.isFavorited ?? false
}
export default {
getScriptList,
getScriptPage,
@@ -209,5 +245,8 @@ export default {
buildCharacterInfo,
buildLifeEventsSummary,
transformToFrontendFormat,
transformListToFrontend
transformListToFrontend,
toggleFavoriteScript,
getFavoriteScriptPage,
checkFavoriteScript,
}
+24 -3
View File
@@ -64,8 +64,28 @@ export const shareEvent = async (eventData = {}) => {
return post('/lifeEvent/share-placeholder', eventData)
}
export const favoriteEvent = async ({ id, favorite }) => {
return post('/lifeEvent/favorite-placeholder', { id, favorite })
/**
* 切换人生事件收藏状态(收藏/取消收藏)
* @param {string} eventId 事件ID
* @returns {Promise<{success: boolean, isFavorited: boolean}>}
*/
export const toggleFavoriteEvent = async (eventId) => {
const res = await post('/lifeEvent/favorite/toggle', { eventId })
return {
success: true,
isFavorited: res.data?.isFavorited ?? false,
favoriteTime: res.data?.favoriteTime ?? null
}
}
/**
* 检查人生事件是否已收藏
* @param {string} eventId 事件ID
* @returns {Promise<boolean>}
*/
export const checkFavoriteEvent = async (eventId) => {
const res = await get('/lifeEvent/favorite/check', { eventId })
return res.data?.isFavorited ?? false
}
const transformToBackendFormat = (frontendData) => {
@@ -153,7 +173,8 @@ export default {
assistEventWriting,
chatAboutEvent,
shareEvent,
favoriteEvent,
toggleFavoriteEvent,
checkFavoriteEvent,
transformToFrontendFormat,
transformListToFrontend
}
+13
View File
@@ -0,0 +1,13 @@
import { get, post, del } from './request.js'
export const createMessage = async (payload) => {
return post('/message/create', payload)
}
export const getMessageById = async (id) => {
return get('/message/detail', { id })
}
export const deleteMessage = async (id) => {
return del('/message/delete', { id })
}
+7
View File
@@ -266,6 +266,13 @@ export const upload = (url, filePath, formData = {}, name = 'file') => {
})
}
export const getApiBaseUrl = () => API_BASE_URL
export const getAuthHeader = () => {
const token = uni.getStorageSync('access_token')
return token ? { Authorization: `Bearer ${token}` } : {}
}
export default {
get,
post,
+92
View File
@@ -0,0 +1,92 @@
import { get, post, put, del } from './request.js'
import { getApiBaseUrl, getAuthHeader } from './request.js'
export const createScriptWithDialogue = async (payload) => {
return post('/epicScript/createWithDialogue', payload)
}
export const streamScriptChat = async ({ operationType, conversationId, messageId, userMessageId, content, scriptId, onDelta, onDone, onError }) => {
const requestTask = uni.request({
url: `${getApiBaseUrl()}/chat/stream`,
method: 'POST',
data: { operationType, conversationId, messageId, userMessageId, content, scriptId },
header: {
'Content-Type': 'application/json',
...getAuthHeader()
},
enableChunked: true,
timeout: 300000,
success: (res) => {
if (res.statusCode >= 400) {
onError?.(res.data?.message || '流式请求失败')
return
}
if (typeof res.data === 'string' && res.data) {
consumeSseText(res.data, onDelta, onDone, onError)
}
},
fail: (error) => {
onError?.(error.errMsg || '流式请求失败')
}
})
requestTask?.onChunkReceived?.((res) => {
try {
const text = decodeChunk(res.data)
consumeSseText(text, onDelta, onDone, onError)
} catch (error) {
onError?.(error.message || '流式解析失败')
}
})
}
export const listMessagesByConversation = async ({ conversationId, includeVersions = false }) => {
return get('/message/listByConversation', { conversationId, includeVersions })
}
export const listMessageVersions = async (messageId) => {
return get('/message/versions', { messageId })
}
export const switchVersion = async ({ scriptId, messageId }) => {
return put('/epicScript/switchVersion', { scriptId, messageId })
}
export const deleteVersion = async (messageId) => {
return del('/message/deleteVersion', { id: messageId })
}
function decodeChunk(chunk) {
if (typeof chunk === 'string') return chunk
const decoder = new TextDecoder('utf-8')
return decoder.decode(new Uint8Array(chunk))
}
function consumeSseText(text, onDelta, onDone, onError) {
const lines = text.split(/\r?\n/)
let eventName = ''
let dataBuffer = ''
lines.forEach((line) => {
if (line.startsWith('event:')) {
eventName = line.slice(6).trim()
} else if (line.startsWith('data:')) {
dataBuffer += line.slice(5).trim()
} else if (line === '' && dataBuffer) {
try {
if (eventName === 'delta') {
onDelta?.(dataBuffer)
} else if (eventName === 'metadata') {
onDone?.({ type: 'metadata', data: JSON.parse(dataBuffer) })
} else if (eventName === 'done') {
onDone?.({ type: 'done', data: JSON.parse(dataBuffer) })
} else if (eventName === 'error') {
onError?.(dataBuffer)
}
} catch (e) {
onError?.(e.message)
}
eventName = ''
dataBuffer = ''
}
})
}
+342
View File
@@ -0,0 +1,342 @@
import { getApiBaseUrl, getAuthHeader } from './request.js'
// 环境检测:H5 模式下存在 window.fetch
const isH5 = typeof window !== 'undefined' && typeof window.fetch === 'function'
// SSE 请求超时时间(毫秒)
const SSE_TIMEOUT_MS = 300000
/**
* 短篇小说外部服务 API 封装
* 通过后端 SSE 代理调用外部 short-novel-service
*/
/**
* 首次发起短篇小说生成(SSE 流式)
* @param {Object} params
* @param {string} params.query - 用户心愿文本
* @param {Function} params.onEvent - 事件回调 (event) => voidevent 包含 type、session_id、payload
* @param {Function} params.onError - 错误回调
* @returns {Object} uni.request 任务对象(用于 abort
*/
export const startNovelStream = ({ query, onEvent, onError }) => {
// H5 环境:使用原生 fetch + ReadableStream
if (isH5) {
return h5NovelStream(
`${getApiBaseUrl()}/shortNovel/stream`,
{ query },
onEvent,
onError
)
}
// 小程序环境:使用 uni.request
let chunkProcessed = false
const task = uni.request({
url: `${getApiBaseUrl()}/shortNovel/stream`,
method: 'POST',
data: { query },
header: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
...getAuthHeader()
},
enableChunked: true,
timeout: SSE_TIMEOUT_MS,
success: (res) => {
if (res.statusCode >= 400) {
onError?.(res.data?.message || '请求失败')
return
}
// 仅在 chunk 未处理时才处理完整 data(避免 H5 双重消费)
if (!chunkProcessed && typeof res.data === 'string' && res.data) {
consumeSseText(res.data, onEvent, onError)
}
},
fail: (error) => {
onError?.(error.errMsg || '网络请求失败')
}
})
task?.onChunkReceived?.((res) => {
chunkProcessed = true
try {
const text = decodeChunk(res.data)
consumeSseText(text, onEvent, onError)
} catch (error) {
onError?.(error.message || '流式解析失败')
}
})
return task
}
/**
* 后续轮次(澄清回答、大纲确认/修改、重试)
* @param {Object} params
* @param {string} params.sessionId - 外部服务的 session_id
* @param {string} params.action - answer_clarification | confirm_outline | modify_outline | retry
* @param {Object} params.payload - 操作载荷
* @param {string} params.originalQuery - 首次发送的原始心愿文本,用于保存到剧本
* @param {Function} params.onEvent
* @param {Function} params.onError
* @returns {Object} uni.request 任务对象
*/
export const followupStream = ({ sessionId, action, payload, originalQuery, onEvent, onError }) => {
// H5 环境:使用原生 fetch + ReadableStream
if (isH5) {
return h5NovelStream(
`${getApiBaseUrl()}/shortNovel/followup`,
{ sessionId, action, payload, originalQuery },
onEvent,
onError
)
}
// 小程序环境:使用 uni.request
let chunkProcessed = false
const task = uni.request({
url: `${getApiBaseUrl()}/shortNovel/followup`,
method: 'POST',
data: { sessionId, action, payload, originalQuery },
header: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
...getAuthHeader()
},
enableChunked: true,
timeout: SSE_TIMEOUT_MS,
success: (res) => {
if (res.statusCode >= 400) {
onError?.(res.data?.message || '请求失败')
return
}
if (!chunkProcessed && typeof res.data === 'string' && res.data) {
consumeSseText(res.data, onEvent, onError)
}
},
fail: (error) => {
onError?.(error.errMsg || '网络请求失败')
}
})
task?.onChunkReceived?.((res) => {
chunkProcessed = true
try {
const text = decodeChunk(res.data)
consumeSseText(text, onEvent, onError)
} catch (error) {
onError?.(error.message || '流式解析失败')
}
})
return task
}
/**
* 解码 chunk 数据
* 兼容 mp-weixin(不支持 TextDecoder
*/
function decodeChunk(chunk) {
if (typeof chunk === 'string') return chunk
// 小程序环境 chunk 可能是 ArrayBuffer,转成 UTF-8 字符串
const bytes = new Uint8Array(chunk)
let result = ''
let i = 0
while (i < bytes.length) {
const byte1 = bytes[i++]
if (byte1 < 0x80) {
result += String.fromCharCode(byte1)
} else if (byte1 < 0xE0) {
const byte2 = bytes[i++]
result += String.fromCharCode(((byte1 & 0x1F) << 6) | (byte2 & 0x3F))
} else if (byte1 < 0xF0) {
const byte2 = bytes[i++]
const byte3 = bytes[i++]
result += String.fromCharCode(((byte1 & 0x0F) << 12) | ((byte2 & 0x3F) << 6) | (byte3 & 0x3F))
} else {
const byte2 = bytes[i++]
const byte3 = bytes[i++]
const byte4 = bytes[i++]
const codePoint = ((byte1 & 0x07) << 18) | ((byte2 & 0x3F) << 12) | ((byte3 & 0x3F) << 6) | (byte4 & 0x3F)
// 转成 UTF-16 代理对
const adjusted = codePoint - 0x10000
result += String.fromCharCode(0xD800 | (adjusted >> 10)) + String.fromCharCode(0xDC00 | (adjusted & 0x3FF))
}
}
return result
}
/**
* H5 环境 SSE 事件解析
* 解析单个 SSE 事件块(已经被 \r\n\r\n 或 \n\n 分隔)
* 事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
* 注意:本函数不依赖块内空行作为结束标记,由调用方负责按双换行切分事件
*/
function h5ConsumeSseText(block, onEvent, onError) {
const lines = block.split(/\r?\n/)
let dataBuffer = ''
for (const line of lines) {
// 移除行尾的 \r(CRLF 兼容)
const cleanLine = line.replace(/\r$/, '')
if (cleanLine.startsWith('data:')) {
dataBuffer += cleanLine.slice(5).trim()
}
// 注意:不再依赖空行作为事件结束标记
// 因为块已经被调用方按 \n\n 切分好了
}
// 遍历完所有行后,直接处理累积的数据
if (dataBuffer) {
if (dataBuffer === '[DONE]') return
try {
const event = JSON.parse(dataBuffer)
onEvent?.(event)
} catch (e) {
onError?.(`SSE 解析失败:${e.message}`)
}
}
}
/**
* H5 环境 SSE 流式读取核心实现
* 使用 Promise.race + setTimeout 实现外部超时控制(兼容微信开发者工具)
* @param {string} url - 请求 URL
* @param {Object} body - 请求体
* @param {Function} onEvent - 事件回调
* @param {Function} onError - 错误回调
* @returns {Object} 包含 abort 方法的对象
*/
function h5NovelStream(url, body, onEvent, onError) {
let reader = null
let timeoutTimer = null
let isCompleted = false
let errorHandled = false // 防止 onError 重复调用
// 统一的清理函数(不处理错误,只清理资源)
const cleanup = () => {
isCompleted = true
if (timeoutTimer) clearTimeout(timeoutTimer)
if (reader) {
reader.cancel().catch(() => {})
}
}
// 安全的错误处理(确保 onError 只调用一次)
const safeOnError = (msg) => {
if (!errorHandled) {
errorHandled = true
cleanup()
onError?.(msg)
}
}
// 创建超时 Promise(不依赖 AbortController
const timeoutPromise = new Promise((_, reject) => {
timeoutTimer = setTimeout(() => {
if (!isCompleted) {
reject(new Error('请求超时(300 秒)'))
}
}, SSE_TIMEOUT_MS)
})
// 创建 fetch Promise
const fetchPromise = fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
...getAuthHeader()
},
body: JSON.stringify(body)
}).then(response => {
// 检查响应状态
if (!response.ok) {
throw new Error(`请求失败:HTTP ${response.status}`)
}
// 获取 ReadableStream 读取器
reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
// 循环读取流数据
function pump() {
return reader.read().then(({ done, value }) => {
if (done) {
// 流正常结束,标记为已完成(后续 abort 不触发错误)
isCompleted = true
if (timeoutTimer) clearTimeout(timeoutTimer)
return
}
if (isCompleted) {
return
}
// 解码二进制块为文本
buffer += decoder.decode(value, { stream: true })
// 解析完整的事件(兼容 CRLF)
const events = buffer.split(/\r?\n\r?\n/)
buffer = events.pop() // 最后一个可能不完整,保留到下次
for (const event of events) {
h5ConsumeSseText(event, onEvent, onError)
}
// 继续读取
return pump()
})
}
return pump()
})
// 使用 Promise.race 竞争超时和 fetch
Promise.race([fetchPromise, timeoutPromise]).then(() => {
// 成功完成,清理资源
if (timeoutTimer) clearTimeout(timeoutTimer)
if (reader) reader.cancel().catch(() => {})
}).catch(err => {
// 任何错误都传递给用户(包括微信开发者工具的 60 秒强制终止)
safeOnError(err.message || '请求超时')
})
// 返回 abort 接口(只清理资源,不触发错误)
return {
abort: () => {
// abort 只负责取消请求和清理资源,不触发 onError
// 真正的错误(超时、网络错误)会通过 Promise.race 的 catch 触发
isCompleted = true
if (timeoutTimer) clearTimeout(timeoutTimer)
if (reader) reader.cancel().catch(() => {})
}
}
}
/**
* 解析 SSE 文本
* 外部服务事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
*/
function consumeSseText(text, onEvent, onError) {
const lines = text.split(/\r?\n/)
let dataBuffer = ''
for (const line of lines) {
if (line.startsWith('data:')) {
dataBuffer += line.slice(5).trim()
} else if (line === '' && dataBuffer) {
const dataStr = dataBuffer
dataBuffer = ''
if (dataStr === '[DONE]') return
try {
const event = JSON.parse(dataStr)
onEvent?.(event)
} catch (e) {
onError?.(`SSE 解析失败:${e.message}`)
}
}
}
}
+47 -4
View File
@@ -16,6 +16,8 @@ const state = reactive({
inspirationRecommendations: [],
paths: [],
currentPath: null,
// 当前心愿实现的 session 元数据(用于 followup 时传递 originalQuery 兜底)
currentSessionMeta: null,
registrationData: {
nickname: '',
gender: '',
@@ -232,10 +234,19 @@ const shareEvent = async (eventData) => {
}
}
const favoriteEvent = async ({ id, favorite }) => {
const toggleEventFavorite = async (eventId) => {
try {
const res = await lifeEventService.favoriteEvent({ id, favorite })
return { success: true, data: res.data }
const result = await lifeEventService.toggleFavoriteEvent(eventId)
return { success: true, data: result }
} catch (error) {
return { success: false, error: error.message }
}
}
const checkEventFavorite = async (eventId) => {
try {
const isFavorited = await lifeEventService.checkFavoriteEvent(eventId)
return { success: true, data: isFavorited }
} catch (error) {
return { success: false, error: error.message }
}
@@ -255,6 +266,34 @@ const fetchScripts = async () => {
}
}
/**
* 设置当前心愿实现的 session 元数据
* 用于在后续 followup 调用中透传 originalQuery/style/length
*
* @param {{ sessionId?: string, scriptId?: string, conversationId?: string, originalQuery: string, style?: string, length?: string }} meta
*/
const setSessionMeta = (meta) => {
if (!meta || !meta.originalQuery) {
return
}
state.currentSessionMeta = { ...meta, updatedAt: Date.now() }
}
/**
* 获取当前心愿实现的 session 元数据
* @returns {object|null}
*/
const getSessionMeta = () => {
return state.currentSessionMeta
}
/**
* 清除当前 session 元数据
*/
const clearSessionMeta = () => {
state.currentSessionMeta = null
}
const createScript = async (scriptData) => {
try {
const res = await epicScriptService.createScript(scriptData)
@@ -479,12 +518,16 @@ export const useAppStore = () => {
assistEventWriting,
chatAboutEvent,
shareEvent,
favoriteEvent,
toggleEventFavorite,
checkEventFavorite,
getEventById,
fetchScripts,
createScript,
updateScript,
deleteScript,
setSessionMeta,
getSessionMeta,
clearSessionMeta,
fetchInspirationRecommendations,
fetchRandomInspirations,
generateScriptFromInspiration,
+7
View File
@@ -104,6 +104,13 @@
<scope>runtime</scope>
</dependency>
<!-- OkHttp:用于 SSE 流式转发,替代 RestTemplate 的缓冲行为 -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<!-- JSON -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
@@ -0,0 +1,37 @@
package com.emotion.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* 短篇小说外部服务配置
*
* @author huazhongmin
* @date 2026-07-19
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "short-novel")
public class ShortNovelConfig {
/**
* 外部服务基础 URL
*/
private String apiBaseUrl;
/**
* 外部服务 API Token
*/
private String apiToken;
/**
* 连接超时(毫秒)
*/
private Integer connectTimeout = 10000;
/**
* 读取超时(毫秒)
*/
private Integer readTimeout = 300000;
}
@@ -3,14 +3,21 @@ package com.emotion.controller;
import com.emotion.common.PageResult;
import com.emotion.common.Result;
import com.emotion.dto.request.EpicScriptCreateRequest;
import com.emotion.dto.request.EpicScriptCreateWithDialogueRequest;
import com.emotion.dto.request.EpicScriptInspirationRequest;
import com.emotion.dto.request.EpicScriptPageRequest;
import com.emotion.dto.request.EpicScriptUpdateRequest;
import com.emotion.dto.request.SwitchVersionRequest;
import com.emotion.dto.request.ScriptFavoriteToggleRequest;
import com.emotion.dto.response.EpicScriptCreateWithDialogueResponse;
import com.emotion.dto.response.EpicScriptInspirationResponse;
import com.emotion.dto.response.EpicScriptResponse;
import com.emotion.dto.response.InspirationSuggestionResponse;
import com.emotion.service.EpicScriptDialogueService;
import com.emotion.dto.response.ScriptFavoriteResponse;
import com.emotion.service.EpicScriptService;
import io.swagger.v3.oas.annotations.Operation;
import com.emotion.service.ScriptFavoriteService;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
@@ -34,6 +41,12 @@ public class EpicScriptController {
@Autowired
private EpicScriptService epicScriptService;
@Autowired
private EpicScriptDialogueService epicScriptDialogueService;
@Autowired
private ScriptFavoriteService scriptFavoriteService;
/**
* 分页查询当前用户的爽文剧本
*/
@@ -109,6 +122,14 @@ public class EpicScriptController {
return Result.success(script);
}
@Operation(summary = "创建剧本并创建对话", description = "用户输入心愿后,创建剧本、创建对话并生成第一条 AI 剧本消息。")
@PostMapping(value = "/createWithDialogue")
public Result<EpicScriptCreateWithDialogueResponse> createWithDialogue(
@Valid @RequestBody EpicScriptCreateWithDialogueRequest request) {
EpicScriptCreateWithDialogueResponse response = epicScriptDialogueService.createWithDialogue(request);
return Result.success(response);
}
/**
* 更新爽文剧本
*/
@@ -135,6 +156,16 @@ public class EpicScriptController {
return Result.success(script);
}
@Operation(summary = "切换剧本当前版本", description = "将指定消息设为剧本当前生效版本。")
@PutMapping(value = "/switchVersion")
public Result<EpicScriptResponse> switchVersion(@Valid @RequestBody SwitchVersionRequest request) {
EpicScriptResponse response = epicScriptService.switchVersion(request.getScriptId(), request.getMessageId());
if (response == null) {
return Result.error("切换失败");
}
return Result.success(response);
}
/**
* 删除爽文剧本
*/
@@ -147,4 +178,31 @@ public class EpicScriptController {
}
return Result.success();
}
// ==================== 剧本收藏 ====================
@Operation(summary = "切换收藏", description = "收藏或取消收藏指定剧本。")
@PostMapping(value = "/favorite/toggle")
public Result<ScriptFavoriteResponse> toggleFavorite(
@Valid @RequestBody ScriptFavoriteToggleRequest request) {
return Result.success(scriptFavoriteService.toggleFavorite(request.getScriptId()));
}
@Operation(summary = "分页查询收藏", description = "分页查询当前用户收藏的剧本列表。")
@GetMapping(value = "/favorite/page")
public Result<PageResult<EpicScriptResponse>> getFavoritePage(
@RequestParam(defaultValue = "1") long current,
@RequestParam(defaultValue = "10") long size) {
return Result.success(scriptFavoriteService.getFavoritePage(current, size));
}
@Operation(summary = "检查收藏状态", description = "检查指定剧本是否已被当前用户收藏。")
@GetMapping(value = "/favorite/check")
public Result<ScriptFavoriteResponse> checkFavorite(@RequestParam String scriptId) {
boolean favorited = scriptFavoriteService.isFavorited(scriptId);
ScriptFavoriteResponse r = new ScriptFavoriteResponse();
r.setScriptId(scriptId);
r.setIsFavorited(favorited);
return Result.success(r);
}
}
@@ -2,10 +2,13 @@ package com.emotion.controller;
import com.emotion.common.PageResult;
import com.emotion.common.Result;
import com.emotion.dto.request.EventFavoriteToggleRequest;
import com.emotion.dto.request.LifeEventCreateRequest;
import com.emotion.dto.request.LifeEventPageRequest;
import com.emotion.dto.request.LifeEventUpdateRequest;
import com.emotion.dto.response.EventFavoriteResponse;
import com.emotion.dto.response.LifeEventResponse;
import com.emotion.service.EventFavoriteService;
import com.emotion.service.LifeEventService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -35,6 +38,9 @@ public class LifeEventController {
@Autowired
private LifeEventService lifeEventService;
@Autowired
private EventFavoriteService eventFavoriteService;
/**
* 分页查询当前用户的生命事件
*/
@@ -150,18 +156,6 @@ public class LifeEventController {
return Result.success(data);
}
@Operation(summary = "收藏占位接口", description = "返回收藏功能的占位响应。")
@PostMapping(value = "/favorite-placeholder")
public Result<Map<String, Object>> favoritePlaceholder(@RequestBody Map<String, Object> request) {
String id = stringValue(request.get("id"), "");
Boolean favorite = Boolean.TRUE.equals(request.get("favorite"));
Map<String, Object> data = new HashMap<>();
data.put("id", id);
data.put("favorite", favorite);
data.put("placeholder", true);
return Result.success(data);
}
private String stringValue(Object value, String fallback) {
if (value == null) {
return fallback;
@@ -170,6 +164,30 @@ public class LifeEventController {
return text.isEmpty() ? fallback : text;
}
// ==================== 人生事件收藏 ====================
@Operation(summary = "切换收藏", description = "收藏或取消收藏指定事件。")
@PostMapping(value = "/favorite/toggle")
public Result<EventFavoriteResponse> toggleFavorite(
@Valid @RequestBody EventFavoriteToggleRequest request) {
return Result.success(eventFavoriteService.toggleFavorite(request.getEventId()));
}
@Operation(summary = "分页查询收藏", description = "分页查询当前用户收藏的事件列表。")
@GetMapping(value = "/favorite/page")
public Result<PageResult<LifeEventResponse>> getFavoritePage(
@RequestParam(defaultValue = "1") long current,
@RequestParam(defaultValue = "10") long size) {
return Result.success(eventFavoriteService.getFavoritePage(current, size));
}
@Operation(summary = "检查收藏状态", description = "检查指定事件是否已被当前用户收藏。")
@GetMapping(value = "/favorite/check")
public Result<EventFavoriteResponse> checkFavorite(
@Parameter(description = "事件 ID") @RequestParam String eventId) {
return Result.success(eventFavoriteService.checkFavorite(eventId));
}
@SuppressWarnings("unchecked")
private List<String> readTags(Object value) {
List<String> tags = new ArrayList<>();
@@ -8,6 +8,7 @@ import com.emotion.dto.request.MessageSearchRequest;
import com.emotion.dto.request.MessageRecentRequest;
import com.emotion.dto.response.MessageResponse;
import com.emotion.service.MessageService;
import com.emotion.service.ScriptMessageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -17,6 +18,7 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
/**
* 消息控制器
@@ -33,6 +35,9 @@ public class MessageController {
@Autowired
private MessageService messageService;
@Autowired
private ScriptMessageService scriptMessageService;
/**
* 创建消息
*/
@@ -86,6 +91,23 @@ public class MessageController {
return Result.success(pageResult);
}
@Operation(summary = "获取会话消息列表", description = "获取指定会话下的完整消息历史。")
@GetMapping(value = "/listByConversation")
public Result<List<MessageResponse>> listByConversation(
@Parameter(description = "会话 ID") @RequestParam String conversationId,
@RequestParam(required = false, defaultValue = "false") Boolean includeVersions) {
List<MessageResponse> messages = scriptMessageService.listByConversation(conversationId, includeVersions);
return Result.success(messages);
}
@Operation(summary = "获取消息版本链", description = "获取指定剧本消息的所有历史版本。")
@GetMapping(value = "/versions")
public Result<List<MessageResponse>> getVersions(
@Parameter(description = "消息 ID") @RequestParam String messageId) {
List<MessageResponse> versions = scriptMessageService.getVersions(messageId);
return Result.success(versions);
}
/**
* 更新消息
*/
@@ -102,7 +124,7 @@ public class MessageController {
/**
* 删除消息
*/
@Operation(summary = "删除消息", description = "删除指定的消息记录")
@Operation(summary = "删除消息", description = "删除指定的消息。")
@DeleteMapping(value = "/delete")
public Result<Void> delete(@Parameter(description = "消息 ID") @RequestParam String id) {
boolean deleted = messageService.deleteMessage(id);
@@ -111,4 +133,14 @@ public class MessageController {
}
return Result.success();
}
@Operation(summary = "删除版本消息", description = "删除指定的剧本版本消息,不能删除当前生效版本。")
@DeleteMapping(value = "/deleteVersion")
public Result<Void> deleteVersion(@Parameter(description = "消息 ID") @RequestParam String id) {
boolean deleted = scriptMessageService.deleteVersion(id);
if (!deleted) {
return Result.error("删除失败");
}
return Result.success();
}
}
@@ -0,0 +1,30 @@
package com.emotion.controller;
import com.emotion.dto.request.ScriptChatStreamRequest;
import com.emotion.service.ScriptChatService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.validation.Valid;
/**
* 剧本对话流式控制器
*/
@RestController
@RequestMapping("/chat")
@Tag(name = "剧本对话", description = "剧本改写、续写、聊天的统一流式接口")
public class ScriptChatController {
@Autowired
private ScriptChatService scriptChatService;
@Operation(summary = "流式对话", description = "统一的剧本改写/续写/聊天流式接口。")
@PostMapping(value = "/stream", produces = "text/event-stream")
public SseEmitter stream(@Valid @RequestBody ScriptChatStreamRequest request) {
return scriptChatService.streamChat(request);
}
}
@@ -0,0 +1,42 @@
package com.emotion.controller;
import com.emotion.dto.request.ShortNovelFollowupRequest;
import com.emotion.dto.request.ShortNovelStreamRequest;
import com.emotion.service.ShortNovelService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.validation.Valid;
/**
* 短篇小说外部服务代理 Controller
*
* @author huazhongmin
* @date 2026-07-19
*/
@Tag(name = "短篇小说管理", description = "短篇小说生成 SSE 代理接口")
@RestController
@RequestMapping("/shortNovel")
public class ShortNovelController {
@Autowired
private ShortNovelService shortNovelService;
@Operation(summary = "首次发起短篇小说生成", description = "用户输入心愿文本,发起流式生成")
@PostMapping("/stream")
public SseEmitter stream(@Valid @RequestBody ShortNovelStreamRequest request) {
return shortNovelService.stream(request);
}
@Operation(summary = "短篇小说后续轮次", description = "处理澄清回答、大纲确认/修改、重试等")
@PostMapping("/followup")
public SseEmitter followup(@Valid @RequestBody ShortNovelFollowupRequest request) {
return shortNovelService.followup(request);
}
}
@@ -0,0 +1,27 @@
package com.emotion.dto.request;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
/**
* 创建剧本并创建对话请求
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class EpicScriptCreateWithDialogueRequest extends BaseRequest {
@NotBlank(message = "心愿/主题不能为空")
private String theme;
private String style;
private String length;
private String characterInfo;
private String lifeEventsSummary;
private Boolean useSocialInsights;
}
@@ -0,0 +1,18 @@
package com.emotion.dto.request;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 人生事件收藏切换请求
*
* @author huazhongmin
* @date 2026-07-19
*/
@Data
public class EventFavoriteToggleRequest {
@NotBlank(message = "事件ID不能为空")
private String eventId;
}
@@ -0,0 +1,29 @@
package com.emotion.dto.request;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
/**
* 剧本对话流式请求
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ScriptChatStreamRequest extends BaseRequest {
@NotBlank(message = "操作类型不能为空")
private String operationType;
@NotBlank(message = "对话ID不能为空")
private String conversationId;
private String messageId;
private String userMessageId;
@NotBlank(message = "消息内容不能为空")
private String content;
private String scriptId;
}
@@ -0,0 +1,17 @@
package com.emotion.dto.request;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 剧本收藏切换请求
*
* @author huazhongmin
* @date 2026-07-19
*/
@Data
public class ScriptFavoriteToggleRequest {
@NotBlank(message = "剧本ID不能为空")
private String scriptId;
}
@@ -0,0 +1,40 @@
package com.emotion.dto.request;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import java.util.Map;
/**
* 短篇小说后续轮次请求(澄清回答、大纲确认/修改等)
*
* @author huazhongmin
* @date 2026-07-19
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ShortNovelFollowupRequest extends BaseRequest {
/**
* 外部服务返回的 session_id
*/
@NotBlank(message = "sessionId 不能为空")
private String sessionId;
/**
* 操作类型:answer_clarification | confirm_outline | modify_outline | retry
*/
@NotBlank(message = "action 不能为空")
private String action;
/**
* 操作载荷(如回答内容、修改意见)
*/
private Map<String, Object> payload;
/**
* 首次发送的原始心愿文本(用于保存剧本时记录用户输入)
*/
private String originalQuery;
}
@@ -0,0 +1,25 @@
package com.emotion.dto.request;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* 短篇小说首次发起请求
*
* @author huazhongmin
* @date 2026-07-19
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ShortNovelStreamRequest extends BaseRequest {
/**
* 用户的心愿文本
*/
@NotBlank(message = "心愿文本不能为空")
@Size(max = 500, message = "心愿文本不能超过 500 字")
private String query;
}
@@ -0,0 +1,20 @@
package com.emotion.dto.request;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
/**
* 切换剧本当前生效版本请求
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class SwitchVersionRequest extends BaseRequest {
@NotBlank(message = "剧本ID不能为空")
private String scriptId;
@NotBlank(message = "消息ID不能为空")
private String messageId;
}
@@ -0,0 +1,34 @@
package com.emotion.dto.response;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 创建剧本并创建对话响应
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class EpicScriptCreateWithDialogueResponse extends BaseResponse {
private String scriptId;
private String conversationId;
private String currentVersionMessageId;
private String title;
private String theme;
private String style;
private String length;
private String plotIntro;
private String plotTurning;
private String plotClimax;
private String plotEnding;
}
@@ -60,6 +60,16 @@ public class EpicScriptResponse extends BaseResponse {
*/
private String plotEnding;
/**
* 对话ID
*/
private String conversationId;
/**
* 当前生效版本的消息ID
*/
private String currentVersionMessageId;
/**
* 完整剧情JSON结构
*/
@@ -0,0 +1,19 @@
package com.emotion.dto.response;
import lombok.Data;
/**
* 人生事件收藏状态响应
*
* @author huazhongmin
* @date 2026-07-19
*/
@Data
public class EventFavoriteResponse {
private String eventId;
private Boolean isFavorited;
private String favoriteTime;
}
@@ -52,4 +52,34 @@ public class MessageResponse extends BaseResponse {
* 消息顺序 - 在同一个会话中递增,用于确保消息展示顺序
*/
private Long messageOrder;
/**
* 剧本ID
*/
private String scriptId;
/**
* 父消息ID
*/
private String parentMessageId;
/**
* 版本号
*/
private Integer versionNumber;
/**
* 元数据
*/
private String metadata;
/**
* 状态
*/
private String status;
/**
* 时间戳
*/
private String timestamp;
}
@@ -0,0 +1,19 @@
package com.emotion.dto.response;
import lombok.Data;
/**
* 剧本收藏状态响应
*
* @author huazhongmin
* @date 2026-07-19
*/
@Data
public class ScriptFavoriteResponse {
private String scriptId;
private Boolean isFavorited;
private String favoriteTime;
}
@@ -43,6 +43,12 @@ public class Conversation extends BaseEntity {
@TableField("title")
private String title;
/**
* 关联剧本ID
*/
@TableField("script_id")
private String scriptId;
/**
* 对话类型
*/
@@ -186,4 +192,10 @@ public class Conversation extends BaseEntity {
*/
@TableField("metadata")
private String metadata;
/**
* 当前生效的剧本消息ID
*/
@TableField("current_message_id")
private String currentMessageId;
}
@@ -87,6 +87,18 @@ public class EpicScript extends BaseEntity {
@TableField(value = "plot_json", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> plotJson;
/**
* 关联对话ID
*/
@TableField("conversation_id")
private String conversationId;
/**
* 当前生效版本消息ID
*/
@TableField("current_version_message_id")
private String currentVersionMessageId;
/**
* 是否当前选中: 0-否, 1-是
*/
@@ -0,0 +1,31 @@
package com.emotion.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.emotion.common.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
/**
* 人生事件收藏关联实体
*
* @author huazhongmin
* @date 2026-07-19
*/
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_event_favorite")
public class EventFavorite extends BaseEntity {
@TableField("user_id")
private String userId;
@TableField("event_id")
private String eventId;
}
@@ -31,6 +31,12 @@ public class Message extends BaseEntity {
@TableField("conversation_id")
private String conversationId;
/**
* 关联剧本ID(冗余字段)
*/
@TableField("script_id")
private String scriptId;
/**
* 消息内容
*/
@@ -133,6 +139,12 @@ public class Message extends BaseEntity {
@TableField("parent_message_id")
private String parentMessageId;
/**
* 版本序号
*/
@TableField("version_number")
private Integer versionNumber;
/**
* 情绪分析结果
*/
@@ -0,0 +1,31 @@
package com.emotion.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.emotion.common.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
/**
* 剧本收藏关联实体
*
* @author huazhongmin
* @date 2026-07-19
*/
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_script_favorite")
public class ScriptFavorite extends BaseEntity {
@TableField("user_id")
private String userId;
@TableField("script_id")
private String scriptId;
}
@@ -0,0 +1,37 @@
package com.emotion.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotion.entity.EventFavorite;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
* 人生事件收藏 Mapper
*
* @author huazhongmin
* @date 2026-07-19
*/
@Mapper
public interface EventFavoriteMapper extends BaseMapper<EventFavorite> {
/**
* 查询用户对某事件的收藏记录(包含已逻辑删除的记录,绕过 @TableLogic 自动过滤)
* 用于 toggle 时判断是否存在历史记录,避免重新收藏时产生重复记录
*/
@Select("SELECT * FROM t_event_favorite WHERE user_id = #{userId} AND event_id = #{eventId} LIMIT 1")
EventFavorite selectAnyByUserAndEvent(@Param("userId") String userId, @Param("eventId") String eventId);
/**
* 物理删除记录(绕过 @TableLogic 的逻辑删除)
*/
@Update("DELETE FROM t_event_favorite WHERE id = #{id}")
int physicalDeleteById(@Param("id") String id);
/**
* 恢复逻辑删除的记录为已收藏状态
*/
@Update("UPDATE t_event_favorite SET is_deleted = 0, update_time = NOW() WHERE id = #{id}")
int restoreById(@Param("id") String id);
}
@@ -0,0 +1,37 @@
package com.emotion.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotion.entity.ScriptFavorite;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
* 剧本收藏Mapper接口
*
* @author huazhongmin
* @date 2026-07-19
*/
@Mapper
public interface ScriptFavoriteMapper extends BaseMapper<ScriptFavorite> {
/**
* 查询用户对某剧本的收藏记录(包含已逻辑删除的记录,绕过 @TableLogic 自动过滤)
* 用于 toggle 时判断是否存在历史记录,避免 unique key 冲突
*/
@Select("SELECT * FROM t_script_favorite WHERE user_id = #{userId} AND script_id = #{scriptId} LIMIT 1")
ScriptFavorite selectAnyByUserAndScript(@Param("userId") String userId, @Param("scriptId") String scriptId);
/**
* 物理删除记录(绕过 @TableLogic 的逻辑删除)
*/
@Update("DELETE FROM t_script_favorite WHERE id = #{id}")
int physicalDeleteById(@Param("id") String id);
/**
* 恢复逻辑删除的记录为已收藏状态
*/
@Update("UPDATE t_script_favorite SET is_deleted = 0, update_time = NOW() WHERE id = #{id}")
int restoreById(@Param("id") String id);
}
@@ -0,0 +1,15 @@
package com.emotion.service;
import com.emotion.dto.request.EpicScriptCreateWithDialogueRequest;
import com.emotion.dto.response.EpicScriptCreateWithDialogueResponse;
/**
* 剧本对话创建服务
*/
public interface EpicScriptDialogueService {
/**
* 创建剧本并创建对话,生成第一条 AI 剧本消息
*/
EpicScriptCreateWithDialogueResponse createWithDialogue(EpicScriptCreateWithDialogueRequest request);
}
@@ -74,6 +74,15 @@ public interface EpicScriptService extends IService<EpicScript> {
*/
EpicScriptResponse selectScript(String id);
/**
* 切换剧本当前生效版本
*
* @param scriptId 剧本ID
* @param messageId 消息ID
* @return 剧本响应
*/
EpicScriptResponse switchVersion(String scriptId, String messageId);
/**
* 删除剧本
*
@@ -0,0 +1,66 @@
package com.emotion.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.emotion.common.PageResult;
import com.emotion.dto.response.EventFavoriteResponse;
import com.emotion.dto.response.LifeEventResponse;
import com.emotion.entity.EventFavorite;
import java.util.Set;
/**
* 人生事件收藏服务接口
*
* @author huazhongmin
* @date 2026-07-19
*/
public interface EventFavoriteService extends IService<EventFavorite> {
/**
* 切换收藏状态(收藏/取消收藏)
*
* @param eventId 事件ID
* @return 收藏状态响应
*/
EventFavoriteResponse toggleFavorite(String eventId);
/**
* 分页查询当前用户收藏的事件列表
*
* @param current 当前页码
* @param size 每页大小
* @return 分页结果
*/
PageResult<LifeEventResponse> getFavoritePage(long current, long size);
/**
* 批量查询已收藏的事件ID集合
*
* @param eventIds 待查询的事件ID集合
* @return 已收藏的事件ID集合
*/
Set<String> getFavoritedEventIds(Set<String> eventIds);
/**
* 检查收藏状态
*
* @param eventId 事件ID
* @return 收藏状态响应
*/
EventFavoriteResponse checkFavorite(String eventId);
/**
* 检查指定事件是否已被当前用户收藏
*
* @param eventId 事件ID
* @return 是否已收藏
*/
boolean isFavorited(String eventId);
/**
* 获取当前用户的收藏总数
*
* @return 收藏总数
*/
long getFavoriteCount();
}
@@ -0,0 +1,15 @@
package com.emotion.service;
import com.emotion.dto.request.ScriptChatStreamRequest;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
* 剧本对话流式服务
*/
public interface ScriptChatService {
/**
* 流式处理改写/续写/聊天请求
*/
SseEmitter streamChat(ScriptChatStreamRequest request);
}
@@ -0,0 +1,28 @@
package com.emotion.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.emotion.common.PageResult;
import com.emotion.dto.response.EpicScriptResponse;
import com.emotion.dto.response.ScriptFavoriteResponse;
import com.emotion.entity.ScriptFavorite;
import java.util.Set;
/**
* 剧本收藏服务接口
*
* @author huazhongmin
* @date 2026-07-19
*/
public interface ScriptFavoriteService extends IService<ScriptFavorite> {
ScriptFavoriteResponse toggleFavorite(String scriptId);
PageResult<EpicScriptResponse> getFavoritePage(long current, long size);
Set<String> getFavoritedScriptIds(Set<String> scriptIds);
boolean isFavorited(String scriptId);
long getFavoriteCount();
}
@@ -0,0 +1,25 @@
package com.emotion.service;
import com.emotion.dto.response.MessageResponse;
import java.util.List;
/**
* 剧本消息版本管理服务
*/
public interface ScriptMessageService {
/**
* 获取对话下的消息历史
*/
List<MessageResponse> listByConversation(String conversationId, boolean includeVersions);
/**
* 获取某条消息的版本链
*/
List<MessageResponse> getVersions(String messageId);
/**
* 删除指定版本消息
*/
boolean deleteVersion(String messageId);
}
@@ -0,0 +1,30 @@
package com.emotion.service;
import com.emotion.dto.request.ShortNovelFollowupRequest;
import com.emotion.dto.request.ShortNovelStreamRequest;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
* 短篇小说外部服务代理接口
*
* @author huazhongmin
* @date 2026-07-19
*/
public interface ShortNovelService {
/**
* 首次发起短篇小说生成请求
*
* @param request 包含用户心愿文本
* @return SSE 发射器
*/
SseEmitter stream(ShortNovelStreamRequest request);
/**
* 处理后续轮次(澄清回答、大纲确认/修改、重试)
*
* @param request 包含 sessionId、action、payload
* @return SSE 发射器
*/
SseEmitter followup(ShortNovelFollowupRequest request);
}
@@ -0,0 +1,434 @@
package com.emotion.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.dto.request.EpicScriptCreateWithDialogueRequest;
import com.emotion.dto.request.ai.AiRuntimeRequest;
import com.emotion.dto.response.EpicScriptCreateWithDialogueResponse;
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
import com.emotion.entity.Conversation;
import com.emotion.entity.EpicScript;
import com.emotion.entity.Message;
import com.emotion.mapper.EpicScriptMapper;
import com.emotion.service.AiRuntimeService;
import com.emotion.service.ConversationService;
import com.emotion.service.EpicScriptDialogueService;
import com.emotion.service.EpicScriptService;
import com.emotion.service.MessageService;
import com.emotion.util.SnowflakeIdGenerator;
import com.emotion.util.UserContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
* 剧本对话创建服务实现类
*/
@Slf4j
@Service
public class EpicScriptDialogueServiceImpl implements EpicScriptDialogueService {
@Autowired
private EpicScriptService epicScriptService;
@Autowired
private ConversationService conversationService;
@Autowired
private MessageService messageService;
@Autowired
private AiRuntimeService aiRuntimeService;
@Autowired
private SnowflakeIdGenerator snowflakeIdGenerator;
@Override
@Transactional(rollbackFor = Exception.class)
public EpicScriptCreateWithDialogueResponse createWithDialogue(EpicScriptCreateWithDialogueRequest request) {
String currentUserId = UserContextHolder.getCurrentUserId();
if (currentUserId == null) {
throw new IllegalStateException("用户未登录");
}
String scriptId = snowflakeIdGenerator.nextIdAsString();
String conversationId = snowflakeIdGenerator.nextIdAsString();
LocalDateTime now = LocalDateTime.now();
// 1. 创建剧本
EpicScript script = new EpicScript();
script.setId(scriptId);
script.setUserId(currentUserId);
script.setTitle("我的人生剧本"); // 默认标题,后续会被 AI 生成的标题覆盖
script.setTheme(request.getTheme());
script.setStyle(StringUtils.hasText(request.getStyle()) ? request.getStyle() : "career");
script.setLength(StringUtils.hasText(request.getLength()) ? request.getLength() : "medium");
script.setConversationId(conversationId);
script.setPlotJson(new HashMap<>());
script.setIsSelected(0);
epicScriptService.save(script);
// 2. 创建对话
Conversation conversation = new Conversation();
conversation.setId(conversationId);
conversation.setUserId(currentUserId);
conversation.setUserType("registered");
conversation.setScriptId(scriptId);
conversation.setTitle(script.getTitle());
conversation.setType("script");
conversation.setConversationStatus("active");
conversation.setStartTime(now);
conversation.setLastActiveTime(now);
conversation.setMessageCount(0);
conversationService.save(conversation);
// 3. 调用 AI 生成完整剧本
String aiOutput = invokeScriptGenerate(request, currentUserId, conversationId, null);
// 4. 解析 AI 输出获取剧本元数据
Map<String, Object> metadata = parseScriptOutput(aiOutput);
// 从 AI 输出中提取标题,如果没有则使用默认值
String aiTitle = (String) metadata.getOrDefault("title", null);
if (StringUtils.hasText(aiTitle)) {
script.setTitle(aiTitle);
}
script.setPlotIntro((String) metadata.getOrDefault("plotIntro", ""));
script.setPlotTurning((String) metadata.getOrDefault("plotTurning", ""));
script.setPlotClimax((String) metadata.getOrDefault("plotClimax", ""));
script.setPlotEnding((String) metadata.getOrDefault("plotEnding", ""));
Map<String, Object> plotJson = script.getPlotJson();
plotJson.putAll(metadata);
script.setPlotJson(plotJson);
// 5. 创建系统欢迎消息(messageOrder = 1
Message systemWelcome = new Message();
systemWelcome.setId(snowflakeIdGenerator.nextIdAsString());
systemWelcome.setConversationId(conversationId);
systemWelcome.setScriptId(scriptId);
systemWelcome.setUserId(currentUserId);
systemWelcome.setContent(buildSystemWelcome(metadata));
systemWelcome.setType("system");
systemWelcome.setSender("system");
systemWelcome.setTimestamp(now);
systemWelcome.setMessageOrder(1L);
systemWelcome.setStatus("sent");
systemWelcome.setIsRead(1);
messageService.createMessage(systemWelcome);
// 6. 创建用户消息(messageOrder = 2
Message userMessage = new Message();
userMessage.setId(snowflakeIdGenerator.nextIdAsString());
userMessage.setConversationId(conversationId);
userMessage.setScriptId(scriptId);
userMessage.setUserId(currentUserId);
userMessage.setContent(request.getTheme());
userMessage.setType("chat");
userMessage.setSender("user");
userMessage.setTimestamp(now);
userMessage.setMessageOrder(2L);
userMessage.setStatus("sent");
userMessage.setIsRead(1);
messageService.createMessage(userMessage);
// 7. 创建 AI 助手开场白消息(messageOrder = 3
String aiContent = buildDisplayContent(metadata);
Message aiMessage = new Message();
aiMessage.setId(snowflakeIdGenerator.nextIdAsString());
aiMessage.setConversationId(conversationId);
aiMessage.setScriptId(scriptId);
aiMessage.setUserId(currentUserId);
aiMessage.setContent(aiContent);
aiMessage.setType("script");
aiMessage.setSender("assistant");
aiMessage.setTimestamp(LocalDateTime.now());
aiMessage.setMessageOrder(3L);
aiMessage.setVersionNumber(1);
aiMessage.setStatus("sent");
aiMessage.setMetadata(JSON.toJSONString(metadata));
messageService.createMessage(aiMessage);
// 8. 更新剧本信息(已在前面完成)
script.setCurrentVersionMessageId(aiMessage.getId());
epicScriptService.updateById(script);
// 9. 更新对话当前消息和消息计数
conversation.setCurrentMessageId(aiMessage.getId());
conversation.setMessageCount(3);
conversationService.updateById(conversation);
// 10. 构建响应
EpicScriptCreateWithDialogueResponse response = new EpicScriptCreateWithDialogueResponse();
BeanUtils.copyProperties(script, response);
response.setScriptId(scriptId);
response.setConversationId(conversationId);
response.setCurrentVersionMessageId(aiMessage.getId());
return response;
}
private String invokeScriptGenerate(EpicScriptCreateWithDialogueRequest request, String userId,
String conversationId, String userMessageId) {
JSONObject inputs = new JSONObject();
inputs.put("input", request.getTheme());
inputs.put("prompt", request.getTheme());
inputs.put("theme", request.getTheme());
inputs.put("style", StringUtils.hasText(request.getStyle()) ? request.getStyle() : "career");
inputs.put("length", StringUtils.hasText(request.getLength()) ? request.getLength() : "medium");
inputs.put("characterInfo", request.getCharacterInfo());
inputs.put("lifeEventsSummary", request.getLifeEventsSummary());
inputs.put("useSocialInsights", request.getUseSocialInsights());
inputs.put("conversationId", conversationId);
inputs.put("userMessageId", userMessageId);
// 使用 AiRuntimeRequest 调用 AI 运行时
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
runtimeRequest.setSceneCode("script_generate");
runtimeRequest.setUserId(userId);
runtimeRequest.setInputs(inputs);
AiRuntimeTestResponse response = aiRuntimeService.test(runtimeRequest);
if (response == null || !"success".equals(response.getStatus()) || !StringUtils.hasText(response.getOutput())) {
String message = response == null ? "AI_RUNTIME_EMPTY_RESPONSE" : response.getErrorMessage();
throw new IllegalStateException(StringUtils.hasText(message) ? message : "AI_RUNTIME_FAILED");
}
return response.getOutput();
}
private Map<String, Object> parseScriptOutput(String output) {
if (!StringUtils.hasText(output)) {
return new HashMap<>();
}
try {
return JSON.parseObject(output, Map.class);
} catch (Exception e) {
log.warn("剧本输出解析失败,使用原始文本作为内容: {}", e.getMessage());
Map<String, Object> fallback = new HashMap<>();
fallback.put("title", "我的人生剧本");
fallback.put("plotIntro", output);
return fallback;
}
}
/**
* 保存小说生成结果到数据库
* 创建 EpicScript、Conversation 和 3 条 Message 记录
*
* @param currentUserId 当前用户 ID
* @param theme 用户主题
* @param fullText 完整小说文本
* @param metadata 大纲元数据(标题等)
* @return Map 包含 scriptId、conversationId、currentVersionMessageId
*/
@Transactional(rollbackFor = Exception.class)
public Map<String, String> saveNovelResult(String currentUserId, String theme,
String fullText, Map<String, Object> metadata) {
String scriptId = snowflakeIdGenerator.nextIdAsString();
String conversationId = snowflakeIdGenerator.nextIdAsString();
LocalDateTime now = LocalDateTime.now();
// 1. 创建剧本
EpicScript script = new EpicScript();
script.setId(scriptId);
script.setUserId(currentUserId);
script.setTitle(metadata != null && metadata.get("title") != null
? (String) metadata.get("title") : "我的人生剧本");
script.setTheme(theme);
// style/length 从 metadata 传入,允许 null(保持原行为兼容),但禁止硬编码 "career"/"short"
// 由前端 ShortNovelServiceImpl 在调用前透传 style/length 到 metadata
script.setStyle(metadata != null && metadata.get("style") != null
? (String) metadata.get("style") : "career");
script.setLength(metadata != null && metadata.get("length") != null
? (String) metadata.get("length") : "short");
script.setConversationId(conversationId);
script.setPlotIntro("");
script.setPlotTurning("");
script.setPlotClimax("");
script.setPlotEnding("");
// 短篇小说全量文本保存到 plotJson.fullContent
// 前端 transformToFrontendFormat 通过 plotJson.fullContent 读取完整内容
Map<String, Object> plotJsonMap = metadata != null ? new HashMap<>(metadata) : new HashMap<>();
plotJsonMap.put("fullContent", fullText);
script.setPlotJson(plotJsonMap);
script.setIsSelected(0);
epicScriptService.save(script);
// 2. 创建对话
Conversation conversation = new Conversation();
conversation.setId(conversationId);
conversation.setUserId(currentUserId);
conversation.setUserType("registered");
conversation.setScriptId(scriptId);
conversation.setTitle(script.getTitle());
conversation.setType("script");
conversation.setConversationStatus("active");
conversation.setStartTime(now);
conversation.setLastActiveTime(now);
// 读取累积的中间步骤(clarification_question/answer/outline
@SuppressWarnings("unchecked")
java.util.List<com.alibaba.fastjson2.JSONObject> stagesHistory =
metadata != null && metadata.get("stagesHistory") instanceof java.util.List
? (java.util.List<com.alibaba.fastjson2.JSONObject>) metadata.get("stagesHistory")
: java.util.Collections.emptyList();
long messageOrder = 1L;
// 2.5 创建中间步骤 messageclarification_question/answer/outline),按累积顺序
for (com.alibaba.fastjson2.JSONObject stage : stagesHistory) {
String kind = stage.getString("kind");
if ("clarification_question".equals(kind)) {
com.alibaba.fastjson2.JSONObject card = stage.getJSONObject("card");
Message qMsg = new Message();
qMsg.setId(snowflakeIdGenerator.nextIdAsString());
qMsg.setConversationId(conversationId);
qMsg.setScriptId(scriptId);
qMsg.setUserId(currentUserId);
qMsg.setContent(card != null ? card.toJSONString() : "{}");
qMsg.setType("clarification_question");
qMsg.setSender("assistant");
qMsg.setTimestamp(now);
qMsg.setMessageOrder(messageOrder++);
qMsg.setStatus("sent");
qMsg.setIsRead(1);
messageService.createMessage(qMsg);
} else if ("clarification_answer".equals(kind)) {
Message aMsg = new Message();
aMsg.setId(snowflakeIdGenerator.nextIdAsString());
aMsg.setConversationId(conversationId);
aMsg.setScriptId(scriptId);
aMsg.setUserId(currentUserId);
aMsg.setContent(stage.getString("answer"));
aMsg.setType("clarification_answer");
aMsg.setSender("user");
aMsg.setTimestamp(now);
aMsg.setMessageOrder(messageOrder++);
aMsg.setStatus("sent");
aMsg.setIsRead(1);
messageService.createMessage(aMsg);
} else if ("outline".equals(kind)) {
com.alibaba.fastjson2.JSONObject outline = stage.getJSONObject("outline");
Message oMsg = new Message();
oMsg.setId(snowflakeIdGenerator.nextIdAsString());
oMsg.setConversationId(conversationId);
oMsg.setScriptId(scriptId);
oMsg.setUserId(currentUserId);
oMsg.setContent(outline != null ? outline.toJSONString() : "{}");
oMsg.setType("outline");
oMsg.setSender("assistant");
oMsg.setTimestamp(now);
oMsg.setMessageOrder(messageOrder++);
oMsg.setStatus("sent");
oMsg.setIsRead(1);
messageService.createMessage(oMsg);
}
}
conversation.setMessageCount((int) (stagesHistory.size() + 3));
conversationService.save(conversation);
// 3. 系统欢迎消息(messageOrder 动态递增)
Message systemWelcome = new Message();
systemWelcome.setId(snowflakeIdGenerator.nextIdAsString());
systemWelcome.setConversationId(conversationId);
systemWelcome.setScriptId(scriptId);
systemWelcome.setUserId(currentUserId);
systemWelcome.setContent("欢迎来到《" + script.getTitle() + "》剧本世界。\n\n让我们一起探索你的心愿故事。");
systemWelcome.setType("system");
systemWelcome.setSender("system");
systemWelcome.setTimestamp(now);
systemWelcome.setMessageOrder(messageOrder++);
systemWelcome.setStatus("sent");
systemWelcome.setIsRead(1);
messageService.createMessage(systemWelcome);
// 4. 用户输入消息(messageOrder 动态递增)
Message userMessage = new Message();
userMessage.setId(snowflakeIdGenerator.nextIdAsString());
userMessage.setConversationId(conversationId);
userMessage.setScriptId(scriptId);
userMessage.setUserId(currentUserId);
userMessage.setContent(theme);
userMessage.setType("chat");
userMessage.setSender("user");
userMessage.setTimestamp(now);
userMessage.setMessageOrder(messageOrder++);
userMessage.setStatus("sent");
userMessage.setIsRead(1);
messageService.createMessage(userMessage);
// 5. AI 小说全文消息(messageOrder 动态递增)
Message aiMessage = new Message();
aiMessage.setId(snowflakeIdGenerator.nextIdAsString());
aiMessage.setConversationId(conversationId);
aiMessage.setScriptId(scriptId);
aiMessage.setUserId(currentUserId);
aiMessage.setContent(fullText);
aiMessage.setType("script");
aiMessage.setSender("assistant");
aiMessage.setTimestamp(now);
aiMessage.setMessageOrder(messageOrder++);
aiMessage.setVersionNumber(1);
aiMessage.setStatus("sent");
aiMessage.setMetadata(metadata != null ? JSON.toJSONString(metadata) : "{}");
messageService.createMessage(aiMessage);
// 6. 更新剧本和对话
script.setCurrentVersionMessageId(aiMessage.getId());
epicScriptService.updateById(script);
conversation.setCurrentMessageId(aiMessage.getId());
conversationService.updateById(conversation);
Map<String, String> result = new HashMap<>();
result.put("scriptId", scriptId);
result.put("conversationId", conversationId);
result.put("currentVersionMessageId", aiMessage.getId());
return result;
}
private String buildDisplayContent(Map<String, Object> metadata) {
StringBuilder sb = new StringBuilder();
appendSection(sb, metadata.get("title"), null);
appendSection(sb, metadata.get("plotIntro"), "序幕:低谷回响");
appendSection(sb, metadata.get("plotTurning"), "转折:契机出现");
appendSection(sb, metadata.get("plotClimax"), "高潮:命运抉择");
appendSection(sb, metadata.get("plotEnding"), "结局:新的开始");
return sb.toString();
}
private String buildSystemWelcome(Map<String, Object> metadata) {
if (metadata == null || metadata.isEmpty()) {
return "欢迎来到情绪博物馆,让我们一起探索你的内心世界。";
}
String title = (String) metadata.getOrDefault("title", "我的人生剧本");
StringBuilder sb = new StringBuilder();
sb.append("欢迎来到《").append(title).append("》剧本世界。\n\n");
sb.append("在这里,你将体验一段关于");
sb.append(metadata.getOrDefault("plotIntro", "情感探索")).append("的旅程。");
return sb.toString();
}
private void appendSection(StringBuilder sb, Object value, String label) {
if (value == null) {
return;
}
String text = String.valueOf(value);
if (!StringUtils.hasText(text)) {
return;
}
if (sb.length() > 0) {
sb.append("\n\n");
}
if (StringUtils.hasText(label)) {
sb.append("").append(label).append("\n");
}
sb.append(text);
}
}
@@ -14,11 +14,15 @@ import com.emotion.dto.response.ai.AiRuntimeTestResponse;
import com.emotion.dto.response.EpicScriptInspirationResponse;
import com.emotion.dto.response.EpicScriptResponse;
import com.emotion.dto.response.InspirationSuggestionResponse;
import com.emotion.entity.Conversation;
import com.emotion.entity.EpicScript;
import com.emotion.entity.Message;
import com.emotion.mapper.EpicScriptMapper;
import com.emotion.service.AiRuntimeService;
import com.emotion.service.ConversationService;
import com.emotion.service.EpicScriptService;
import com.emotion.service.LifePathService;
import com.emotion.service.MessageService;
import com.emotion.service.ScriptContextService;
import com.emotion.service.TtsTaskService;
import com.emotion.util.UserContextHolder;
@@ -79,6 +83,12 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
@Autowired
private TtsTaskService ttsTaskService;
@Autowired
private MessageService messageService;
@Autowired
private ConversationService conversationService;
@Override
public PageResult<EpicScriptResponse> getPageByCurrentUser(EpicScriptPageRequest request) {
String currentUserId = UserContextHolder.getCurrentUserId();
@@ -137,7 +147,12 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
.eq(EpicScript::getIsDeleted, 0)
.orderByDesc(EpicScript::getCreateTime);
return this.list(wrapper).stream()
List<EpicScript> scripts = this.list(wrapper);
for (EpicScript script : scripts) {
ensureConversationForScript(script, currentUserId);
}
return scripts.stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
}
@@ -155,6 +170,9 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
return null;
}
// 历史剧本兼容:conversation 不存在时自动创建
ensureConversationForScript(script, currentUserId);
return convertToResponse(script);
}
@@ -675,6 +693,46 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
return convertToResponse(script);
}
@Override
@org.springframework.transaction.annotation.Transactional(rollbackFor = Exception.class)
public EpicScriptResponse switchVersion(String scriptId, String messageId) {
String currentUserId = UserContextHolder.getCurrentUserId();
EpicScript script = this.getById(scriptId);
if (script == null || !script.getUserId().equals(currentUserId)) {
return null;
}
Message message = messageService.getById(messageId);
if (message == null || !"script".equals(message.getType())
|| !script.getConversationId().equals(message.getConversationId())) {
throw new IllegalStateException("消息不属于该剧本");
}
script.setCurrentVersionMessageId(messageId);
Map<String, Object> metadata = parseMetadata(message.getMetadata());
script.setTitle((String) metadata.getOrDefault("title", script.getTitle()));
script.setPlotIntro((String) metadata.getOrDefault("plotIntro", ""));
script.setPlotTurning((String) metadata.getOrDefault("plotTurning", ""));
script.setPlotClimax((String) metadata.getOrDefault("plotClimax", ""));
script.setPlotEnding((String) metadata.getOrDefault("plotEnding", ""));
Map<String, Object> plotJson = script.getPlotJson();
if (plotJson == null) {
plotJson = new java.util.HashMap<>();
}
plotJson.putAll(metadata);
script.setPlotJson(plotJson);
this.updateById(script);
Conversation conversation = conversationService.getById(script.getConversationId());
if (conversation != null) {
conversation.setCurrentMessageId(messageId);
conversationService.updateById(conversation);
}
return convertToResponse(script);
}
@Override
public boolean deleteScript(String id) {
EpicScript script = this.getById(id);
@@ -695,6 +753,20 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
return this.removeById(id);
}
/**
* 解析消息元数据JSON
*/
private Map<String, Object> parseMetadata(String metadataJson) {
if (!StringUtils.hasText(metadataJson)) {
return new java.util.HashMap<>();
}
try {
return com.alibaba.fastjson2.JSON.parseObject(metadataJson, Map.class);
} catch (Exception e) {
return new java.util.HashMap<>();
}
}
/**
* 转换为响应对象
*/
@@ -711,4 +783,41 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
}
return response;
}
/**
* 为剧本补建 conversation(历史剧本兼容)
*
* 触发条件:script.conversationId 指向的 conversation 不存在(DB 中无对应记录)
* 副作用:创建新 conversation,回填 script.conversationId,更新 script
*/
private void ensureConversationForScript(EpicScript script, String currentUserId) {
// 修复:列表查询是读路径,禁止在读路径上触发数据库写操作(隐性写)
// 历史剧本兼容补建 conversation 的逻辑应放在独立的修复脚本/管理接口中,而不是每次列表查询都触发
// 这里只做检查和日志,不做任何写入
String conversationId = script.getConversationId();
if (!org.springframework.util.StringUtils.hasText(conversationId)) {
log.debug("[EpicScript] 剧本缺少 conversationId: scriptId={}", script.getId());
return;
}
Conversation existing = conversationService.getById(conversationId);
if (existing == null) {
log.warn("[EpicScript] 剧本 conversationId 指向不存在的 conversation: scriptId={}, conversationId={}",
script.getId(), conversationId);
}
}
private void createAndAttachConversation(EpicScript script, String currentUserId) {
// 修复:保留方法签名但不自动调用(避免读路径隐性写)
// 如需补建 conversation,请显式调用此方法(如:剧本详情页、单条修复脚本等)
Conversation conversation = new Conversation();
conversation.setUserId(currentUserId);
conversation.setScriptId(script.getId());
conversation.setTitle(script.getTitle() != null ? script.getTitle() : "未命名剧本");
conversation.setType("script");
conversation.setConversationStatus("active");
conversationService.save(conversation);
script.setConversationId(conversation.getId());
this.updateById(script);
}
}
@@ -0,0 +1,171 @@
package com.emotion.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.common.PageResult;
import com.emotion.exception.BusinessException;
import com.emotion.dto.response.EventFavoriteResponse;
import com.emotion.dto.response.LifeEventResponse;
import com.emotion.entity.EventFavorite;
import com.emotion.mapper.EventFavoriteMapper;
import com.emotion.service.EventFavoriteService;
import com.emotion.service.LifeEventService;
import com.emotion.util.UserContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
/**
* 人生事件收藏服务实现
*
* @author huazhongmin
* @date 2026-07-19
*/
@Slf4j
@Service
public class EventFavoriteServiceImpl extends ServiceImpl<EventFavoriteMapper, EventFavorite>
implements EventFavoriteService {
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Autowired
private LifeEventService lifeEventService;
@Override
@Transactional(rollbackFor = Exception.class)
public EventFavoriteResponse toggleFavorite(String eventId) {
String userId = UserContextHolder.getCurrentUserId();
EventFavoriteResponse r = new EventFavoriteResponse();
r.setEventId(eventId);
if (!StringUtils.hasText(userId)) {
r.setIsFavorited(false);
return r;
}
// 验证事件存在且属于当前用户
LifeEventResponse event = lifeEventService.getEventById(eventId);
if (event == null) {
throw new BusinessException("事件不存在或无权限访问");
}
// 查询时绕过 @TableLogic 自动过滤,包含已逻辑删除的记录
// 避免重新收藏时产生重复记录
EventFavorite existing = baseMapper.selectAnyByUserAndEvent(userId, eventId);
if (existing != null) {
if (existing.getIsDeleted() != null && existing.getIsDeleted() == 0) {
// 已收藏 -> 物理删除(取消收藏)
baseMapper.physicalDeleteById(existing.getId());
r.setIsFavorited(false);
} else {
// 历史逻辑删除记录 -> 恢复为已收藏
baseMapper.restoreById(existing.getId());
r.setIsFavorited(true);
if (existing.getCreateTime() != null) {
r.setFavoriteTime(existing.getCreateTime().format(FMT));
}
}
} else {
EventFavorite fav = new EventFavorite();
fav.setUserId(userId);
fav.setEventId(eventId);
this.save(fav);
r.setIsFavorited(true);
if (fav.getCreateTime() != null) {
r.setFavoriteTime(fav.getCreateTime().format(FMT));
}
}
return r;
}
@Override
public PageResult<LifeEventResponse> getFavoritePage(long current, long size) {
String userId = UserContextHolder.getCurrentUserId();
if (!StringUtils.hasText(userId)) {
return new PageResult<>();
}
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
w.eq(EventFavorite::getUserId, userId)
.eq(EventFavorite::getIsDeleted, 0)
.orderByDesc(EventFavorite::getCreateTime);
Page<EventFavorite> page = new Page<>(current, size);
Page<EventFavorite> result = this.page(page, w);
List<LifeEventResponse> records = result.getRecords().stream()
.map(EventFavorite::getEventId)
.map(lifeEventService::getEventById)
.filter(Objects::nonNull)
.collect(Collectors.toList());
PageResult<LifeEventResponse> pr = new PageResult<>();
pr.setCurrent(result.getCurrent());
pr.setSize(result.getSize());
pr.setTotal(result.getTotal());
pr.setPages(result.getPages());
pr.setRecords(records);
return pr;
}
@Override
public Set<String> getFavoritedEventIds(Set<String> eventIds) {
String userId = UserContextHolder.getCurrentUserId();
if (!StringUtils.hasText(userId) || eventIds == null || eventIds.isEmpty()) {
return Collections.emptySet();
}
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
w.eq(EventFavorite::getUserId, userId)
.in(EventFavorite::getEventId, eventIds)
.eq(EventFavorite::getIsDeleted, 0);
return this.list(w).stream()
.map(EventFavorite::getEventId)
.collect(Collectors.toSet());
}
@Override
public EventFavoriteResponse checkFavorite(String eventId) {
EventFavoriteResponse r = new EventFavoriteResponse();
r.setEventId(eventId);
r.setIsFavorited(isFavorited(eventId));
return r;
}
@Override
public boolean isFavorited(String eventId) {
String userId = UserContextHolder.getCurrentUserId();
if (!StringUtils.hasText(userId) || !StringUtils.hasText(eventId)) {
return false;
}
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
w.eq(EventFavorite::getUserId, userId)
.eq(EventFavorite::getEventId, eventId)
.eq(EventFavorite::getIsDeleted, 0);
return this.count(w) > 0;
}
@Override
public long getFavoriteCount() {
String userId = UserContextHolder.getCurrentUserId();
if (!StringUtils.hasText(userId)) {
return 0L;
}
LambdaQueryWrapper<EventFavorite> w = new LambdaQueryWrapper<>();
w.eq(EventFavorite::getUserId, userId)
.eq(EventFavorite::getIsDeleted, 0);
return this.count(w);
}
}
@@ -0,0 +1,286 @@
package com.emotion.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.emotion.dto.request.ScriptChatStreamRequest;
import com.emotion.dto.request.ai.AiRuntimeRequest;
import com.emotion.dto.response.ai.AiRuntimeTestResponse;
import com.emotion.entity.Conversation;
import com.emotion.entity.EpicScript;
import com.emotion.entity.Message;
import com.emotion.service.*;
import com.emotion.util.SnowflakeIdGenerator;
import com.emotion.util.UserContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
@Slf4j
@Service
public class ScriptChatServiceImpl implements ScriptChatService {
@Autowired
private ConversationService conversationService;
@Autowired
private EpicScriptService epicScriptService;
@Autowired
private MessageService messageService;
@Autowired
private AiRuntimeService aiRuntimeService;
@Autowired
private SnowflakeIdGenerator snowflakeIdGenerator;
@Autowired
private Executor taskExecutor;
@Override
public SseEmitter streamChat(ScriptChatStreamRequest request) {
String currentUserId = UserContextHolder.getCurrentUserId();
if (currentUserId == null) {
throw new IllegalStateException("用户未登录");
}
Conversation conversation = conversationService.getById(request.getConversationId());
if (conversation == null || !currentUserId.equals(conversation.getUserId())) {
throw new IllegalStateException("对话不存在或无权限");
}
EpicScript script = epicScriptService.getById(conversation.getScriptId());
if (script == null) {
throw new IllegalStateException("关联剧本不存在");
}
SseEmitter emitter = new SseEmitter(300_000L);
CompletableFuture.runAsync(() -> {
try {
processStream(request, currentUserId, conversation, script, emitter);
} catch (Exception e) {
log.error("流式处理失败", e);
sendError(emitter, e.getMessage());
}
}, taskExecutor);
return emitter;
}
private void processStream(ScriptChatStreamRequest request, String userId,
Conversation conversation, EpicScript script, SseEmitter emitter) {
String operationType = request.getOperationType();
String conversationId = conversation.getId();
String scriptId = script.getId();
// 查询用户指令消息
Message userMessage = messageService.getById(request.getUserMessageId());
if (userMessage == null || !conversationId.equals(userMessage.getConversationId())) {
throw new IllegalStateException("用户指令消息不存在");
}
// 查询被改写的 AI 消息(rewrite/continue 需要)
Message targetMessage = null;
if ("rewrite".equals(operationType) || "continue".equals(operationType)) {
targetMessage = messageService.getById(request.getMessageId());
if (targetMessage == null || !"script".equals(targetMessage.getType())) {
throw new IllegalStateException("被改写的剧本消息不存在");
}
}
// 组装 prompt 和上下文
String prompt = buildPrompt(operationType, targetMessage, userMessage);
Map<String, Object> extraInputs = buildExtraInputs(operationType, conversationId,
request.getMessageId(), request.getUserMessageId(), scriptId);
// 调用 AI(当前项目 AI 运行时不直接支持 SSE,先按非流式调用,再按字符分片发送)
String aiOutput = invokeAiRuntime(operationType, prompt, userId, extraInputs);
// 保存 AI 消息
Map<String, Object> metadata = parseScriptOutput(aiOutput);
String content = buildDisplayContent(metadata);
int nextVersionNumber = calculateNextVersionNumber(targetMessage);
Message aiMessage = new Message();
aiMessage.setId(snowflakeIdGenerator.nextIdAsString());
aiMessage.setConversationId(conversationId);
aiMessage.setScriptId(scriptId);
aiMessage.setUserId(userId);
aiMessage.setContent(content);
aiMessage.setType("chat".equals(operationType) ? "chat" : "script");
aiMessage.setSender("assistant");
aiMessage.setTimestamp(LocalDateTime.now());
aiMessage.setMessageOrder(calculateNextMessageOrder(conversationId));
aiMessage.setParentMessageId(targetMessage != null ? targetMessage.getId() : null);
aiMessage.setVersionNumber("chat".equals(operationType) ? 0 : nextVersionNumber);
aiMessage.setStatus("sent");
aiMessage.setMetadata("chat".equals(operationType) ? null : JSON.toJSONString(metadata));
messageService.createMessage(aiMessage);
// 更新剧本快照(仅 script 类型)
if (!"chat".equals(operationType)) {
script.setCurrentVersionMessageId(aiMessage.getId());
script.setTitle((String) metadata.getOrDefault("title", script.getTitle()));
script.setPlotIntro((String) metadata.getOrDefault("plotIntro", ""));
script.setPlotTurning((String) metadata.getOrDefault("plotTurning", ""));
script.setPlotClimax((String) metadata.getOrDefault("plotClimax", ""));
script.setPlotEnding((String) metadata.getOrDefault("plotEnding", ""));
Map<String, Object> plotJson = script.getPlotJson();
if (plotJson == null) {
plotJson = new HashMap<>();
}
plotJson.putAll(metadata);
script.setPlotJson(plotJson);
epicScriptService.updateById(script);
conversation.setCurrentMessageId(aiMessage.getId());
}
conversation.setLastActiveTime(LocalDateTime.now());
conversation.setMessageCount(conversation.getMessageCount() + 1);
conversationService.updateById(conversation);
// 流式发送内容(简单分片模拟,后续可接入真正 SSE)
try {
emitter.send(SseEmitter.event().name("start").data("{}"));
int chunkSize = 20;
for (int i = 0; i < content.length(); i += chunkSize) {
String chunk = content.substring(i, Math.min(i + chunkSize, content.length()));
emitter.send(SseEmitter.event().name("delta").data(chunk));
Thread.sleep(30);
}
emitter.send(SseEmitter.event().name("metadata").data(JSON.toJSONString(metadata)));
emitter.send(SseEmitter.event().name("done").data(JSON.toJSONString(Map.of(
"messageId", aiMessage.getId(),
"versionNumber", aiMessage.getVersionNumber()
))));
emitter.complete();
} catch (IOException | InterruptedException e) {
log.error("发送 SSE 事件失败", e);
emitter.completeWithError(e);
}
}
private String buildPrompt(String operationType, Message targetMessage, Message userMessage) {
StringBuilder sb = new StringBuilder();
sb.append("操作类型:").append(operationType).append("\n");
if (targetMessage != null && StringUtils.hasText(targetMessage.getMetadata())) {
sb.append("当前剧本内容:\n").append(targetMessage.getMetadata()).append("\n\n");
}
sb.append("用户指令:").append(userMessage.getContent());
return sb.toString();
}
private Map<String, Object> buildExtraInputs(String operationType, String conversationId,
String messageId, String userMessageId, String scriptId) {
Map<String, Object> extras = new HashMap<>();
extras.put("operationType", operationType);
extras.put("conversationId", conversationId);
extras.put("messageId", messageId);
extras.put("userMessageId", userMessageId);
extras.put("scriptId", scriptId);
return extras;
}
private String invokeAiRuntime(String operationType, String input, String userId, Map<String, Object> extraInputs) {
JSONObject inputs = new JSONObject();
inputs.put("input", input);
inputs.put("prompt", input);
inputs.put("message", input);
if (extraInputs != null) {
inputs.putAll(extraInputs);
}
AiRuntimeRequest runtimeRequest = new AiRuntimeRequest();
runtimeRequest.setSceneCode("script_generate");
runtimeRequest.setUserId(userId);
runtimeRequest.setInputs(inputs);
AiRuntimeTestResponse response = aiRuntimeService.test(runtimeRequest);
if (response == null || !"success".equals(response.getStatus()) || !StringUtils.hasText(response.getOutput())) {
String message = response == null ? "AI_RUNTIME_EMPTY_RESPONSE" : response.getErrorMessage();
throw new IllegalStateException(StringUtils.hasText(message) ? message : "AI_RUNTIME_FAILED");
}
return response.getOutput();
}
private Map<String, Object> parseScriptOutput(String output) {
if (!StringUtils.hasText(output)) {
return new HashMap<>();
}
try {
return JSON.parseObject(output, Map.class);
} catch (Exception e) {
log.warn("JSON解析失败,返回空Map: {}", output, e);
return new HashMap<>();
}
}
private String buildDisplayContent(Map<String, Object> metadata) {
StringBuilder sb = new StringBuilder();
appendSection(sb, metadata.get("title"), null);
appendSection(sb, metadata.get("plotIntro"), "序幕:低谷回响");
appendSection(sb, metadata.get("plotTurning"), "转折:契机出现");
appendSection(sb, metadata.get("plotClimax"), "高潮:命运抉择");
appendSection(sb, metadata.get("plotEnding"), "结局:新的开始");
return sb.toString();
}
private void appendSection(StringBuilder sb, Object value, String label) {
if (value == null) {
return;
}
String text = String.valueOf(value);
if (!StringUtils.hasText(text)) {
return;
}
if (sb.length() > 0) {
sb.append("\n\n");
}
if (StringUtils.hasText(label)) {
sb.append("").append(label).append("\n");
}
sb.append(text);
}
private int calculateNextVersionNumber(Message targetMessage) {
if (targetMessage == null || !StringUtils.hasText(targetMessage.getParentMessageId())) {
return 1;
}
// 查询同一 parentMessageId 下最大 version_number
LambdaQueryWrapper<Message> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Message::getParentMessageId, targetMessage.getParentMessageId())
.orderByDesc(Message::getVersionNumber)
.last("LIMIT 1");
Message latest = messageService.getOne(wrapper);
if (latest == null) {
return 1;
}
return latest.getVersionNumber() + 1;
}
private long calculateNextMessageOrder(String conversationId) {
Message lastMessage = messageService.getLastMessageByConversationId(conversationId);
return lastMessage == null ? 1L : lastMessage.getMessageOrder() + 1;
}
private void sendError(SseEmitter emitter, String message) {
try {
emitter.send(SseEmitter.event().name("error").data(message));
emitter.complete();
} catch (IOException e) {
log.error("发送错误事件失败", e);
emitter.completeWithError(e);
}
}
}
@@ -0,0 +1,163 @@
package com.emotion.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.common.PageResult;
import com.emotion.exception.BusinessException;
import com.emotion.dto.response.EpicScriptResponse;
import com.emotion.dto.response.ScriptFavoriteResponse;
import com.emotion.entity.ScriptFavorite;
import com.emotion.mapper.ScriptFavoriteMapper;
import com.emotion.service.EpicScriptService;
import com.emotion.service.ScriptFavoriteService;
import com.emotion.util.UserContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
/**
* 剧本收藏服务实现
*
* @author huazhongmin
* @date 2026-07-19
*/
@Slf4j
@Service
public class ScriptFavoriteServiceImpl extends ServiceImpl<ScriptFavoriteMapper, ScriptFavorite>
implements ScriptFavoriteService {
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Autowired
private EpicScriptService epicScriptService;
@Override
@Transactional(rollbackFor = Exception.class)
public ScriptFavoriteResponse toggleFavorite(String scriptId) {
String userId = UserContextHolder.getCurrentUserId();
ScriptFavoriteResponse r = new ScriptFavoriteResponse();
r.setScriptId(scriptId);
if (!StringUtils.hasText(userId)) {
r.setIsFavorited(false);
return r;
}
// 验证剧本存在且属于当前用户
EpicScriptResponse script = epicScriptService.getScriptById(scriptId);
if (script == null) {
throw new BusinessException("剧本不存在或无权限访问");
}
// 查询时绕过 @TableLogic 自动过滤包含已逻辑删除的记录
// 避免 unique key 冲突导致无法重新收藏
ScriptFavorite existing = baseMapper.selectAnyByUserAndScript(userId, scriptId);
if (existing != null) {
if (existing.getIsDeleted() != null && existing.getIsDeleted() == 0) {
// 已收藏 -> 物理删除取消收藏
baseMapper.physicalDeleteById(existing.getId());
r.setIsFavorited(false);
} else {
// 历史逻辑删除记录 -> 恢复为已收藏
baseMapper.restoreById(existing.getId());
r.setIsFavorited(true);
if (existing.getCreateTime() != null) {
r.setFavoriteTime(existing.getCreateTime().format(FMT));
}
}
} else {
ScriptFavorite fav = new ScriptFavorite();
fav.setUserId(userId);
fav.setScriptId(scriptId);
this.save(fav);
r.setIsFavorited(true);
if (fav.getCreateTime() != null) {
r.setFavoriteTime(fav.getCreateTime().format(FMT));
}
}
return r;
}
@Override
public PageResult<EpicScriptResponse> getFavoritePage(long current, long size) {
String userId = UserContextHolder.getCurrentUserId();
if (!StringUtils.hasText(userId)) {
return new PageResult<>();
}
LambdaQueryWrapper<ScriptFavorite> w = new LambdaQueryWrapper<>();
w.eq(ScriptFavorite::getUserId, userId)
.eq(ScriptFavorite::getIsDeleted, 0)
.orderByDesc(ScriptFavorite::getCreateTime);
Page<ScriptFavorite> page = new Page<>(current, size);
Page<ScriptFavorite> result = this.page(page, w);
List<EpicScriptResponse> records = result.getRecords().stream()
.map(ScriptFavorite::getScriptId)
.map(epicScriptService::getScriptById)
.filter(Objects::nonNull)
.collect(Collectors.toList());
PageResult<EpicScriptResponse> pr = new PageResult<>();
pr.setCurrent(result.getCurrent());
pr.setSize(result.getSize());
pr.setTotal(result.getTotal());
pr.setPages(result.getPages());
pr.setRecords(records);
return pr;
}
@Override
public Set<String> getFavoritedScriptIds(Set<String> scriptIds) {
String userId = UserContextHolder.getCurrentUserId();
if (!StringUtils.hasText(userId) || scriptIds == null || scriptIds.isEmpty()) {
return Collections.emptySet();
}
LambdaQueryWrapper<ScriptFavorite> w = new LambdaQueryWrapper<>();
w.eq(ScriptFavorite::getUserId, userId)
.in(ScriptFavorite::getScriptId, scriptIds)
.eq(ScriptFavorite::getIsDeleted, 0);
return this.list(w).stream()
.map(ScriptFavorite::getScriptId)
.collect(Collectors.toSet());
}
@Override
public boolean isFavorited(String scriptId) {
String userId = UserContextHolder.getCurrentUserId();
if (!StringUtils.hasText(userId) || !StringUtils.hasText(scriptId)) {
return false;
}
LambdaQueryWrapper<ScriptFavorite> w = new LambdaQueryWrapper<>();
w.eq(ScriptFavorite::getUserId, userId)
.eq(ScriptFavorite::getScriptId, scriptId)
.eq(ScriptFavorite::getIsDeleted, 0);
return this.count(w) > 0;
}
@Override
public long getFavoriteCount() {
String userId = UserContextHolder.getCurrentUserId();
if (!StringUtils.hasText(userId)) {
return 0L;
}
LambdaQueryWrapper<ScriptFavorite> w = new LambdaQueryWrapper<>();
w.eq(ScriptFavorite::getUserId, userId)
.eq(ScriptFavorite::getIsDeleted, 0);
return this.count(w);
}
}
@@ -0,0 +1,151 @@
package com.emotion.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.emotion.dto.response.MessageResponse;
import com.emotion.entity.Conversation;
import com.emotion.entity.EpicScript;
import com.emotion.entity.Message;
import com.emotion.service.ConversationService;
import com.emotion.service.EpicScriptService;
import com.emotion.service.MessageService;
import com.emotion.service.ScriptMessageService;
import com.emotion.util.UserContextHolder;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 剧本消息版本管理服务实现
*/
@Service
public class ScriptMessageServiceImpl implements ScriptMessageService {
@Autowired
private MessageService messageService;
@Autowired
private ConversationService conversationService;
@Autowired
private EpicScriptService epicScriptService;
@Override
public List<MessageResponse> listByConversation(String conversationId, boolean includeVersions) {
String currentUserId = UserContextHolder.getCurrentUserId();
Conversation conversation = conversationService.getById(conversationId);
// 对话不存在视为"尚无消息"返回空列表前端用临时 ID 查询时走这条分支
if (conversation == null) {
return java.util.Collections.emptyList();
}
// 对话存在但归属他人权限问题必须暴露
if (!conversation.getUserId().equals(currentUserId)) {
throw new IllegalStateException("无权限访问该对话");
}
LambdaQueryWrapper<Message> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Message::getConversationId, conversationId);
if (!includeVersions) {
// 只返回当前生效版本链上的消息parent_message_id 为空的 script 消息 + 所有 chat 消息
wrapper.and(w -> w.isNull(Message::getParentMessageId).or().ne(Message::getType, "script"));
}
wrapper.orderByAsc(Message::getMessageOrder);
List<Message> messages = messageService.list(wrapper);
return messages.stream().map(this::convertToResponse).collect(Collectors.toList());
}
@Override
public List<MessageResponse> getVersions(String messageId) {
String currentUserId = UserContextHolder.getCurrentUserId();
Message message = messageService.getById(messageId);
if (message == null) {
throw new IllegalStateException("消息不存在");
}
Conversation conversation = conversationService.getById(message.getConversationId());
if (conversation == null || !conversation.getUserId().equals(currentUserId)) {
throw new IllegalStateException("无权限");
}
// 找到根消息
String rootId = message.getParentMessageId() == null ? message.getId() : message.getParentMessageId();
Message root = messageService.getById(rootId);
LambdaQueryWrapper<Message> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Message::getParentMessageId, rootId)
.orderByAsc(Message::getVersionNumber);
List<Message> versions = messageService.list(wrapper);
List<MessageResponse> result = new ArrayList<>();
if (root != null) {
result.add(convertToResponse(root));
}
result.addAll(versions.stream().map(this::convertToResponse).collect(Collectors.toList()));
return result;
}
@Override
public boolean deleteVersion(String messageId) {
String currentUserId = UserContextHolder.getCurrentUserId();
Message message = messageService.getById(messageId);
if (message == null) {
throw new IllegalStateException("消息不存在");
}
Conversation conversation = conversationService.getById(message.getConversationId());
if (conversation == null || !conversation.getUserId().equals(currentUserId)) {
throw new IllegalStateException("无权限");
}
EpicScript script = epicScriptService.getById(conversation.getScriptId());
if (script == null) {
throw new IllegalStateException("剧本不存在");
}
// 不能删除当前生效版本
if (messageId.equals(script.getCurrentVersionMessageId())) {
throw new IllegalStateException("不能删除当前生效版本");
}
// 不能删除有子版本的父消息
if (message.getParentMessageId() == null) {
LambdaQueryWrapper<Message> childWrapper = new LambdaQueryWrapper<>();
childWrapper.eq(Message::getParentMessageId, messageId);
long childCount = messageService.count(childWrapper);
if (childCount > 0) {
throw new IllegalStateException("不能删除有子版本的父消息");
}
}
String parentId = message.getParentMessageId();
boolean removed = messageService.removeById(messageId);
if (!removed) {
return false;
}
// 重新计算同一 parent 下剩余版本的 version_number
if (StringUtils.hasText(parentId)) {
LambdaQueryWrapper<Message> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Message::getParentMessageId, parentId)
.orderByAsc(Message::getMessageOrder);
List<Message> siblings = messageService.list(wrapper);
for (int i = 0; i < siblings.size(); i++) {
Message sibling = siblings.get(i);
sibling.setVersionNumber(i + 2);
messageService.updateById(sibling);
}
}
return true;
}
private MessageResponse convertToResponse(Message message) {
MessageResponse response = new MessageResponse();
BeanUtils.copyProperties(message, response);
response.setId(message.getId());
return response;
}
}
@@ -0,0 +1,458 @@
package com.emotion.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.emotion.config.ShortNovelConfig;
import com.emotion.dto.request.ShortNovelFollowupRequest;
import com.emotion.dto.request.ShortNovelStreamRequest;
import com.emotion.exception.BusinessException;
import com.emotion.service.ShortNovelService;
import com.emotion.util.UserContextHolder;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.BufferedSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.EOFException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* 短篇小说外部服务代理实现
* 使用 OkHttp 逐行流式读取外部 SSE 响应通过 SseEmitter 转发给小程序前端
* OkHttp ResponseBody.source() 是真正的网络流式读取不会缓冲整个响应体
*
* @author huazhongmin
* @date 2026-07-19
*/
@Service
public class ShortNovelServiceImpl implements ShortNovelService {
private static final Logger log = LoggerFactory.getLogger(ShortNovelServiceImpl.class);
private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool();
/**
* sessionId originalQuery 内存缓存
* 用于在 followup 调用中当请求未带 originalQuery 时兜底恢复
* 会话超时 30 分钟 Spring Session 默认对齐
*/
private static final java.util.concurrent.ConcurrentHashMap<String, CachedSessionMeta> SESSION_ORIGINAL_QUERY_CACHE = new java.util.concurrent.ConcurrentHashMap<>();
private static final long SESSION_TTL_MS = 30 * 60 * 1000L;
/**
* sessionId -> 中间步骤累积列表 SSE 流保存 clarification_question/answer/outline
* 用于在 novel_done 时统一创建 message还原完整生成流程
* 会话超时 30 分钟 SESSION_ORIGINAL_QUERY_CACHE 对齐
*/
private static final java.util.concurrent.ConcurrentHashMap<String, java.util.List<JSONObject>> SESSION_STAGES_ACCUMULATOR =
new java.util.concurrent.ConcurrentHashMap<>();
/**
* session 元数据 originalQuery 和缓存时间
*/
private static class CachedSessionMeta {
final String originalQuery;
final long createdAt;
final String style;
final String length;
CachedSessionMeta(String originalQuery, String style, String length) {
this.originalQuery = originalQuery;
this.style = style;
this.length = length;
this.createdAt = System.currentTimeMillis();
}
boolean isExpired() {
return System.currentTimeMillis() - createdAt > SESSION_TTL_MS;
}
}
/**
* 缓存 session 元数据
*/
private static void cacheSessionMeta(String sessionId, String originalQuery, String style, String length) {
if (sessionId == null || sessionId.isEmpty() || originalQuery == null || originalQuery.isEmpty()) {
return;
}
SESSION_ORIGINAL_QUERY_CACHE.put(sessionId, new CachedSessionMeta(originalQuery, style, length));
}
/**
* 从缓存中获取 originalQuery过期则返回 null
*/
private static String getCachedOriginalQuery(String sessionId) {
if (sessionId == null || sessionId.isEmpty()) {
return null;
}
CachedSessionMeta meta = SESSION_ORIGINAL_QUERY_CACHE.get(sessionId);
if (meta == null) {
return null;
}
if (meta.isExpired()) {
SESSION_ORIGINAL_QUERY_CACHE.remove(sessionId);
return null;
}
return meta.originalQuery;
}
/**
* 累积中间步骤到 session 缓存
* 包级可见便于单元测试
*
* @param sessionId 会话ID
* @param stageEntry 步骤条目 kind 字段clarification_question/clarification_answer/outline
*/
static void accumulateStage(String sessionId, JSONObject stageEntry) {
if (sessionId == null || sessionId.isEmpty() || stageEntry == null) {
return;
}
SESSION_STAGES_ACCUMULATOR.computeIfAbsent(sessionId,
k -> new java.util.concurrent.CopyOnWriteArrayList<>()).add(stageEntry);
}
/**
* 读取并清理 session 累积的中间步骤
* 包级可见便于单元测试
*
* @param sessionId 会话ID
* @return 步骤列表按累积顺序无则返回空列表
*/
static java.util.List<JSONObject> drainStages(String sessionId) {
if (sessionId == null || sessionId.isEmpty()) {
return java.util.Collections.emptyList();
}
java.util.List<JSONObject> stages = SESSION_STAGES_ACCUMULATOR.remove(sessionId);
return stages != null ? stages : java.util.Collections.emptyList();
}
@Autowired
private ShortNovelConfig config;
@Autowired
private EpicScriptDialogueServiceImpl epicScriptDialogueServiceImpl;
/**
* OkHttp 客户端连接/读取超时与 Spring 配置对齐支持 SSE 长连接流式读取
* 延迟初始化避免 @Autowired 注入前 config 还未填充
*/
private volatile OkHttpClient okHttpClient;
private OkHttpClient getOkHttpClient() {
if (okHttpClient == null) {
synchronized (this) {
if (okHttpClient == null) {
okHttpClient = new OkHttpClient.Builder()
.connectTimeout(config.getConnectTimeout(), TimeUnit.MILLISECONDS)
.readTimeout(config.getReadTimeout(), TimeUnit.MILLISECONDS)
.writeTimeout(config.getConnectTimeout(), TimeUnit.MILLISECONDS)
.build();
}
}
}
return okHttpClient;
}
private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8");
private static final MediaType SSE_ACCEPT_TYPE = MediaType.parse("text/event-stream");
@Override
public SseEmitter stream(ShortNovelStreamRequest request) {
String currentUserId = UserContextHolder.getCurrentUserId();
if (currentUserId == null) {
throw new BusinessException("用户未登录");
}
Map<String, Object> upstreamBody = new HashMap<>();
upstreamBody.put("user_id", currentUserId);
upstreamBody.put("message_id", "web_" + System.currentTimeMillis());
upstreamBody.put("query", request.getQuery());
return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId,
request.getQuery(), null);
}
@Override
public SseEmitter followup(ShortNovelFollowupRequest request) {
String currentUserId = UserContextHolder.getCurrentUserId();
if (currentUserId == null) {
throw new BusinessException("用户未登录");
}
// originalQuery 兜底如果请求中为空尝试从 session 恢复避免因 originalQuery 缺失导致 novel_done 不保存
String effectiveOriginalQuery = request.getOriginalQuery();
if (!org.springframework.util.StringUtils.hasText(effectiveOriginalQuery)) {
// 从会话缓存恢复 originalQueryfallback 机制确保不丢失用户原始输入
effectiveOriginalQuery = getCachedOriginalQuery(request.getSessionId());
if (effectiveOriginalQuery == null) {
log.warn("[ShortNovel] followup 缺少 originalQuery 且无 session 缓存: sessionId={}",
request.getSessionId());
}
}
// 累积用户澄清回答到 session 缓存用于详情页还原完整澄清流程
if ("answer_clarification".equals(request.getAction())
&& request.getSessionId() != null && !request.getSessionId().isEmpty()) {
Object answer = request.getPayload() != null ? request.getPayload().get("answer") : null;
if (answer == null && request.getPayload() != null) {
// 兼容前端可能用 value 字段传答案
answer = request.getPayload().get("value");
}
if (answer != null) {
JSONObject entry = new JSONObject();
entry.put("kind", "clarification_answer");
entry.put("answer", String.valueOf(answer));
accumulateStage(request.getSessionId(), entry);
log.debug("[ShortNovel] 累积澄清回答: sessionId={}", request.getSessionId());
}
}
Map<String, Object> upstreamBody = new HashMap<>();
upstreamBody.put("session_id", request.getSessionId());
upstreamBody.put("user_id", currentUserId);
upstreamBody.put("message_id", "web_" + System.currentTimeMillis());
upstreamBody.put("action", request.getAction());
upstreamBody.put("payload", request.getPayload());
return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId,
effectiveOriginalQuery, request.getSessionId());
}
/**
* 通用 SSE 转发逻辑OkHttp 流式读取
*
* @param path 外部服务路径
* @param body 请求体
* @param currentUserId 当前用户ID从主线程捕获避免 ThreadLocal 在异步线程中失效
* @param originalQuery 原始用户查询首次请求时传入用于保存
*/
private SseEmitter forwardSse(String path, Map<String, Object> body, String currentUserId,
String originalQuery, String knownSessionId) {
SseEmitter emitter = new SseEmitter(config.getReadTimeout().longValue());
EXECUTOR.execute(() -> {
String url = config.getApiBaseUrl() + path;
// 本次流式会话的中间步骤元数据用于 novel_done 时一并持久化到 message metadata
// 包含 outline_created outline JSON以及其他需要保留到数据库的中间状态
final java.util.concurrent.atomic.AtomicReference<JSONObject> stageMetaRef =
new java.util.concurrent.atomic.AtomicReference<>(new JSONObject());
// 当前流的 sessionIdstream 首次从 status 事件获取followup 从请求参数获取
final String[] currentSessionId = {knownSessionId};
// 累积 novel_delta 事件的文本内容
// 上游服务通过 novel_delta 流式发送小说内容novel_done 事件可能不含 full_text
// 因此需要在后端累积所有 delta novel_done 时使用累积的完整文本保存到数据库
final StringBuilder novelContentAccumulator = new StringBuilder();
try {
// 构建 OkHttp 请求
Request okhttpRequest = new Request.Builder()
.url(url)
.post(RequestBody.create(JSON.toJSONString(body), JSON_MEDIA_TYPE))
.addHeader("X-API-Token", config.getApiToken())
.addHeader("Accept", "text/event-stream")
.build();
// OkHttp 执行请求ResponseBody.source() 是真正的网络流
try (Response response = getOkHttpClient().newCall(okhttpRequest).execute()) {
if (!response.isSuccessful()) {
throw new BusinessException("上游服务返回错误: HTTP " + response.code());
}
ResponseBody responseBody = response.body();
if (responseBody == null) {
throw new BusinessException("上游服务返回空响应体");
}
// source() 返回的 BufferedSource 逐行从网络 socket 读取不会缓冲整个响应
BufferedSource source = responseBody.source();
log.info("[ShortNovel SSE] 开始读取上游响应: path={}, userId={}", path, currentUserId);
StringBuilder dataBuffer = new StringBuilder();
while (!source.exhausted()) {
String line;
try {
line = source.readUtf8LineStrict();
} catch (EOFException e) {
break;
}
log.debug("[ShortNovel SSE] 读取到行: length={}, timestamp={}",
line.length(), System.currentTimeMillis());
if (line.startsWith("data:")) {
dataBuffer.append(line.substring(5).trim());
} else if (line.isEmpty() && dataBuffer.length() > 0) {
String dataStr = dataBuffer.toString();
dataBuffer.setLength(0);
if ("[DONE]".equals(dataStr)) {
continue;
}
try {
JSONObject event = JSON.parseObject(dataStr);
String type = event.getString("type");
long eventTimestamp = System.currentTimeMillis();
log.info("[ShortNovel SSE] 处理事件: type={}, timestamp={}", type, eventTimestamp);
// 从事件顶层取 session_id 同步 sessionId
// 上游事件结构与前端对齐session_id type 同级不在 payload
// 修复首次 stream status 处理从 payload 取不到 session_id导致 currentSessionId 保持 null
// 第一个 clarification_card sessionId null 不累积详情页少显示第一个选项卡
String topSessionId = event.getString("session_id");
if (topSessionId != null && !topSessionId.isEmpty()) {
currentSessionId[0] = topSessionId;
}
// 拦截 status 事件缓存 sessionId originalQuery 映射 followup 兜底
if ("status".equals(type)) {
JSONObject payload = event.getJSONObject("payload");
if (payload != null) {
String sessionId = payload.getString("session_id");
if (sessionId != null && !sessionId.isEmpty()) {
currentSessionId[0] = sessionId;
// 缓存原始参数originalQuery/style/length
String style = payload.getString("style");
String length = payload.getString("length");
cacheSessionMeta(sessionId, originalQuery, style, length);
log.debug("[ShortNovel] 缓存 session 元数据: sessionId={}", sessionId);
}
}
}
// 累积中间步骤元数据用于 novel_done 时一并持久化+ 累积小说正文
JSONObject stagePayload = event.getJSONObject("payload");
if (stagePayload != null) {
if ("outline_created".equals(type)) {
JSONObject outline = stagePayload.getJSONObject("outline");
if (outline != null) {
stageMetaRef.get().put("outline", outline);
// 累积到全局 session 缓存供详情页还原大纲步骤
if (currentSessionId[0] != null) {
JSONObject entry = new JSONObject();
entry.put("kind", "outline");
entry.put("outline", outline);
accumulateStage(currentSessionId[0], entry);
}
}
} else if ("clarification_card".equals(type)) {
JSONObject card = stagePayload.getJSONObject("card");
if (card != null) {
stageMetaRef.get().put("clarification", card);
// 累积到全局 session 缓存供详情页还原完整澄清流程
if (currentSessionId[0] != null) {
JSONObject entry = new JSONObject();
entry.put("kind", "clarification_question");
entry.put("card", card);
accumulateStage(currentSessionId[0], entry);
}
}
} else if ("novel_delta".equals(type)) {
// 累积小说正文片段上游通过 novel_delta 逐段发送内容
String delta = stagePayload.getString("delta");
if (delta != null) {
novelContentAccumulator.append(delta);
}
}
}
// 拦截 novel_done 事件保存到数据库
if ("novel_done".equals(type)) {
JSONObject payload = event.getJSONObject("payload");
log.info("[ShortNovel SSE] novel_done 事件: originalQuery={}, payload={}",
originalQuery, payload != null);
if (payload != null && originalQuery != null && !originalQuery.trim().isEmpty()) {
String fullText = payload.getString("full_text");
// 兜底如果上游 novel_done 未携带 full_text使用已累积的 novel_delta 内容
if (fullText == null || fullText.isEmpty()) {
fullText = novelContentAccumulator.toString();
log.info("[ShortNovel SSE] novel_done 未携带 full_text,使用累积内容: length={}",
fullText.length());
}
if (fullText != null && !fullText.isEmpty()) {
Map<String, Object> metadata = new HashMap<>();
if (payload.get("title") != null) {
metadata.put("title", payload.get("title"));
}
// 把整个 payload包含 outlinechapters 保存到 metadata
// 详情页可读取 metadata 显示中间步骤大纲章节等
metadata.put("fullPayload", payload);
// 把会话期间累积的 stage 元数据也合并进来
JSONObject stageMeta = stageMetaRef.get();
if (stageMeta != null && !stageMeta.isEmpty()) {
metadata.put("stages", stageMeta);
}
// 读取跨流累积的中间步骤clarification/outline供详情页还原完整流程
if (currentSessionId[0] != null) {
java.util.List<JSONObject> stagesHistory = drainStages(currentSessionId[0]);
if (!stagesHistory.isEmpty()) {
metadata.put("stagesHistory", stagesHistory);
log.info("[ShortNovel SSE] 读取累积中间步骤: count={}", stagesHistory.size());
}
}
log.info("[ShortNovel SSE] 开始保存小说: userId={}, queryLength={}, textLength={}",
currentUserId, originalQuery.length(), fullText.length());
Map<String, String> saveResult = epicScriptDialogueServiceImpl.saveNovelResult(
currentUserId,
originalQuery,
fullText,
metadata);
// 注入 scriptId 到事件中
payload.put("scriptId", saveResult.get("scriptId"));
payload.put("conversationId", saveResult.get("conversationId"));
payload.put("currentVersionMessageId", saveResult.get("currentVersionMessageId"));
log.info("[ShortNovel SSE] 小说保存成功: scriptId={}", saveResult.get("scriptId"));
} else {
log.warn("[ShortNovel SSE] novel_done 事件缺少 full_text");
}
} else {
log.warn("[ShortNovel SSE] novel_done 事件跳过保存: originalQuery={}, payload={}",
originalQuery, payload);
}
}
// 逐事件转发给前端OkHttp 每读到一个完整 SSE 事件就立即转发
emitter.send(SseEmitter.event().name(type).data(event.toJSONString()));
emitter.send(SseEmitter.event().comment("")); // 触发 flush
} catch (Exception parseEx) {
log.warn("SSE 事件解析失败: {}", parseEx.getMessage());
}
}
}
log.info("[ShortNovel SSE] 完成读取上游响应");
emitter.complete();
}
} catch (Exception e) {
log.error("SSE 代理异常: {}", e.getMessage(), e);
try {
JSONObject errorEvent = new JSONObject();
errorEvent.put("type", "error");
JSONObject errorPayload = new JSONObject();
errorPayload.put("code", "UPSTREAM_ERROR");
errorPayload.put("message", "外部服务异常,请稍后重试");
errorEvent.put("payload", errorPayload);
emitter.send(SseEmitter.event().name("error").data(errorEvent.toJSONString()));
} catch (Exception ignored) {
// 忽略发送错误事件时的异常
}
emitter.completeWithError(e);
}
});
return emitter;
}
}
@@ -142,3 +142,10 @@ async:
queue-capacity: 200
keep-alive-seconds: 60
thread-name-prefix: single-async-
# 短篇小说外部服务配置
short-novel:
api-base-url: http://49.232.138.53:8010
api-token: c67d4a95b0bb92470a24d534302c0d40
connect-timeout: 10000
read-timeout: 300000
@@ -0,0 +1,45 @@
-- 剧本表新增对话关联字段
ALTER TABLE t_epic_script
ADD COLUMN conversation_id VARCHAR(64) DEFAULT NULL COMMENT '关联对话ID',
ADD COLUMN current_version_message_id VARCHAR(64) DEFAULT NULL COMMENT '当前生效版本消息ID',
ADD UNIQUE KEY uk_user_conversation (user_id, conversation_id),
ADD KEY idx_conversation_id (conversation_id);
-- 对话表新增剧本关联字段
ALTER TABLE t_conversation
ADD COLUMN script_id VARCHAR(64) DEFAULT NULL COMMENT '关联剧本ID',
ADD COLUMN current_message_id VARCHAR(64) DEFAULT NULL COMMENT '当前生效的剧本消息ID',
ADD KEY idx_script_id (script_id),
ADD KEY idx_user_type (user_id, type);
-- 消息表新增剧本关联和版本字段
ALTER TABLE t_message
ADD COLUMN script_id VARCHAR(64) DEFAULT NULL COMMENT '关联剧本ID(冗余字段)',
ADD COLUMN version_number INT DEFAULT 0 COMMENT '版本序号(同一parent_message_id下递增)',
ADD KEY idx_script_id (script_id),
ADD KEY idx_parent_message_id (parent_message_id);
-- 注意:type、metadata 字段已存在,无需新增
-- 历史数据回填:根据 plot_json 中的 conversationId 回填 t_epic_script.conversation_id
UPDATE t_epic_script s
LEFT JOIN t_conversation c ON c.id = JSON_UNQUOTE(JSON_EXTRACT(s.plot_json, '$.conversationId'))
SET s.conversation_id = c.id
WHERE s.conversation_id IS NULL
AND JSON_UNQUOTE(JSON_EXTRACT(s.plot_json, '$.conversationId')) IS NOT NULL;
-- 历史数据回填:根据 conversation_id 回填 t_conversation.script_id
UPDATE t_conversation c
JOIN t_epic_script s ON s.conversation_id = c.id
SET c.script_id = s.id
WHERE c.script_id IS NULL;
-- 历史消息回填 script_id 和 type
UPDATE t_message m
JOIN t_conversation c ON c.id = m.conversation_id
SET m.script_id = c.script_id
WHERE c.script_id IS NOT NULL;
UPDATE t_message
SET type = 'chat'
WHERE type IS NULL OR type = '';
@@ -0,0 +1,12 @@
CREATE TABLE t_event_favorite (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
event_id VARCHAR(64) NOT NULL COMMENT '事件ID',
create_by VARCHAR(64) COMMENT '创建人',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by VARCHAR(64) COMMENT '更新人',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除:0-否 1-是',
remarks VARCHAR(500) COMMENT '备注',
UNIQUE KEY uk_user_event (user_id, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人生事件收藏关联表';

Some files were not shown because too many files have changed in this diff Show More