Compare commits

...

21 Commits

Author SHA1 Message Date
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
12 changed files with 4865 additions and 246 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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 保存)
- 不新增澄清问答的轮数限制(保持上游服务决定的轮数)
- 不改造列表页的搜索、排序、筛选功能
+330 -183
View File
@@ -10,42 +10,74 @@
</view> </view>
<scroll-view class="scroll" scroll-y :show-scrollbar="false"> <scroll-view class="scroll" scroll-y :show-scrollbar="false">
<view class="hero-card kos-card"> <view class="detail-flow">
<text class="eyebrow">PARALLEL LIFE</text> <view
<text class="script-title">{{ script?.title || '未命名剧本' }}</text> v-for="msg in resultMessages"
<text class="script-summary">{{ script?.summary || '命运正在整理章节。' }}</text> :key="msg.id"
<view class="stats"> >
<view class="stat"> <!-- 用户心愿气泡 -->
<text class="stat-value">{{ script?.style || '爽文' }}</text> <view v-if="msg.role === 'user' && msg.kind === 'text'" class="chat-bubble user">
<text class="stat-label">风格</text> <text>{{ msg.content }}</text>
</view> </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>
<view class="stat">
<text class="stat-value">{{ script?.wordCount || 0 }}</text> <!-- 大纲beats 列表只读 -->
<text class="stat-label">字数</text> <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="msg.content" />
</view> </view>
</view> </view>
</view>
<view class="tabs kos-card"> <view v-if="resultMessages.length === 0" class="empty-flow">
<view class="tab" :class="{ active: activeTab === 'content' }" @click="activeTab = 'content'">正文</view> <text>暂无剧本内容</text>
<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> </view>
</view> </view>
</scroll-view> </scroll-view>
@@ -68,16 +100,16 @@ import Markdown from '../../components/Markdown.vue'
import analytics from '../../services/analytics.js' import analytics from '../../services/analytics.js'
import { useTtsPlayer } from '../../composables/useTtsPlayer.js' import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js' import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
import { listMessagesByConversation } from '../../services/scriptChat.js'
const store = useAppStore() const store = useAppStore()
const activeTab = ref('content')
const scriptId = ref('') const scriptId = ref('')
const script = ref(null) const script = ref(null)
const resultMessages = ref([])
const pagePath = '/pages/main/ScriptDetailView' const pagePath = '/pages/main/ScriptDetailView'
const ttsPlayer = useTtsPlayer({ pagePath }) const ttsPlayer = useTtsPlayer({ pagePath })
const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 10 }) const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 10 })
const fullContent = computed(() => script.value?.content || '暂无正文内容。')
const lengthText = computed(() => { const lengthText = computed(() => {
const map = { short: '短篇', medium: '中篇', long: '长篇' } const map = { short: '短篇', medium: '中篇', long: '长篇' }
return map[script.value?.length] || script.value?.length || '中篇' return map[script.value?.length] || script.value?.length || '中篇'
@@ -93,39 +125,123 @@ const detailTtsIcon = computed(() => {
return ttsPlayer.playing.value ? 'Ⅱ' : '▶' return ttsPlayer.playing.value ? 'Ⅱ' : '▶'
}) })
const outline = computed(() => { /**
const text = fullContent.value * 从后端 message 列表重建统一对话消息流
const parts = text.split(/\n{2,}/).filter(Boolean) * 复刻 ScriptView 生成时的 chat-bubble 展示结构
if (parts.length > 1) { */
return parts.slice(0, 8).map((part, index) => { const rebuildResultMessages = (scriptData, messages) => {
const clean = part.replace(/[#>*_`]/g, '').trim() const result = []
const lines = clean.split('\n').filter(Boolean)
return { // 1. 用户心愿作为首条 user 消息(如果 messages 中没有 user 心愿消息则用 theme 补)
title: lines[0]?.replace(/[【】]/g, '') || `章节 ${index + 1}`, const hasUserWishInMessages = messages.some(m => m.sender === 'user' && m.type === 'chat')
text: lines.slice(1).join(' ').slice(0, 120) || clean.slice(0, 120) if (!hasUserWishInMessages && scriptData?.theme) {
} result.push({
id: `wish-${scriptData.id}`,
role: 'user',
kind: 'text',
content: scriptData.theme
}) })
} }
return [ // 2. 澄清问答按 messageOrder 升序配对(DB 中 answer 可能先于 question 存入)
{ title: '起点', text: script.value?.theme || '从真实的人生经验出发。' }, const clarificationQuestions = messages
{ title: '转折', text: '关键机会出现,旧有困境开始松动。' }, .filter(m => m.type === 'clarification_question')
{ title: '高光', text: '主角完成一次真正的选择,并看见新的自我。' }, .sort((a, b) => (a.messageOrder || 0) - (b.messageOrder || 0))
{ title: '回响', text: '剧本落回现实,转化为可执行路径。' } const clarificationAnswers = messages
] .filter(m => m.type === 'clarification_answer')
}) .sort((a, b) => (a.messageOrder || 0) - (b.messageOrder || 0))
const loadScript = async () => { // 为每个 question 配对它之后第一个未分配的 answer(按 messageOrder 升序)
if (!scriptId.value) return const answerAssigned = new Set()
ttsPlayer.reset() const questionAnswerMap = new Map()
script.value = store.getScriptById(scriptId.value) const unmatchedAnswers = []
if (!script.value) { clarificationQuestions.forEach(q => {
await store.fetchScripts() const matched = clarificationAnswers.find(a =>
script.value = store.getScriptById(scriptId.value) !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 = () => { const continueCurrent = () => {
@@ -153,9 +269,32 @@ const goBack = () => {
} }
onMounted(async () => { onMounted(async () => {
// scriptId 从路由参数获取
const pages = getCurrentPages() const pages = getCurrentPages()
scriptId.value = pages[pages.length - 1]?.options?.id || '' const currentPage = pages[pages.length - 1]
await loadScript() 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.trackPageView(pagePath, { script_id: scriptId.value })
analytics.track('script_detail_view', { analytics.track('script_detail_view', {
script_id: scriptId.value, script_id: scriptId.value,
@@ -242,144 +381,152 @@ onUnmounted(() => {
padding: 0 30rpx; padding: 0 30rpx;
} }
.hero-card { /* chat-bubble 流程视图样式 */
border-radius: 32rpx; .detail-flow {
padding: 36rpx 30rpx; padding: 20rpx 0 160rpx;
} }
.chat-bubble {
.eyebrow { margin-bottom: 24rpx;
display: block; padding: 24rpx;
color: #c186ff; border-radius: 24rpx;
font-size: 18rpx;
letter-spacing: 6rpx;
} }
.chat-bubble.user {
.script-title { background: rgba(168, 85, 247, 0.12);
display: block; align-self: flex-end;
margin-top: 14rpx; margin-left: 80rpx;
color: #fff;
font-size: 44rpx;
line-height: 1.18;
font-weight: 900;
} }
.chat-bubble.system {
.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;
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
margin-right: 40rpx;
} }
.card-answered {
.stat-value,
.stat-label {
display: block;
}
.stat-value {
color: #fff;
font-size: 24rpx;
font-weight: 800;
}
.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;
display: flex;
align-items: center;
justify-content: center;
color: rgba(224, 214, 243, 0.64);
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);
}
.article-card {
margin: 24rpx 0 34rpx;
border-radius: 30rpx;
padding: 30rpx;
}
.outline-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18rpx; gap: 12rpx;
margin: 24rpx 0 34rpx;
} }
.card-question-text {
.outline-card { font-size: 28rpx;
border-radius: 26rpx; color: rgba(255, 255, 255, 0.9);
padding: 26rpx; font-weight: 500;
display: flex;
gap: 20rpx;
} }
.card-answer-row {
.outline-node {
width: 48rpx;
height: 48rpx;
flex-shrink: 0;
border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: baseline;
justify-content: center; gap: 8rpx;
color: #fff; 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; font-size: 22rpx;
font-weight: 900; color: rgba(255, 255, 255, 0.55);
background: linear-gradient(135deg, #a855ff, #4f46e5); line-height: 1.5;
box-shadow: 0 0 22rpx rgba(168, 85, 255, 0.48);
} }
.card-option-mark {
.outline-body { position: absolute;
flex: 1; right: 14rpx;
min-width: 0; top: 50%;
transform: translateY(-50%);
font-size: 30rpx;
color: rgba(220, 195, 255, 1);
font-weight: 700;
} }
.outline-title { .outline-title {
display: block; font-size: 32rpx;
font-weight: 600;
color: #fff; color: #fff;
font-size: 27rpx; margin-bottom: 12rpx;
font-weight: 900;
} }
.outline-logline {
.outline-text { font-size: 26rpx;
display: block; color: rgba(255, 255, 255, 0.7);
margin-top: 10rpx; margin-bottom: 16rpx;
color: rgba(223, 211, 245, 0.7); }
.outline-beats {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.beat-item {
display: flex;
gap: 16rpx;
}
.beat-order {
font-size: 24rpx; font-size: 24rpx;
line-height: 1.6; 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;
}
.empty-flow {
padding: 80rpx 0;
text-align: center;
color: rgba(255, 255, 255, 0.4);
font-size: 28rpx;
} }
.bottom-actions { .bottom-actions {
@@ -86,6 +86,10 @@
<text v-for="tag in getTags(script)" :key="tag" class="tag">{{ tag }}</text> <text v-for="tag in getTags(script)" :key="tag" class="tag">{{ tag }}</text>
</view> </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> <text class="summary">{{ script.summary || script.content || '一段正在生成中的平行人生剧本。' }}</text>
<view class="meta-row"> <view class="meta-row">
@@ -134,7 +138,7 @@
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch, onMounted } from 'vue'
import { useAppStore } from '../../stores/app.js' import { useAppStore } from '../../stores/app.js'
import { toggleFavoriteScript, checkFavoriteScript } from '../../services/epicScript.js' import { toggleFavoriteScript, checkFavoriteScript } from '../../services/epicScript.js'
@@ -148,6 +152,12 @@ const activeMenuId = ref('')
const deleteTarget = ref(null) const deleteTarget = ref(null)
const deletingScript = ref(false) const deletingScript = ref(false)
// 每次打开历史列表(组件挂载)时主动拉取最新剧本,避免显示 store 旧缓存
// 解决:生成完小说后打开历史列表看不到最新记录的问题
onMounted(() => {
store.fetchScripts()
})
// 固定 5 个分类标签:全部 / 短篇 / 中篇 / 长篇 / 收藏夹 // 固定 5 个分类标签:全部 / 短篇 / 中篇 / 长篇 / 收藏夹
const typeTabs = [ const typeTabs = [
{ label: '全部', value: 'all' }, { label: '全部', value: 'all' },
@@ -243,6 +253,25 @@ const getProgress = (script) => Math.max(0, Math.min(99, Number(script.progress
const getInitial = (script) => (script.title || '剧').slice(0, 1) 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) => { const isFavorite = (script) => {
return Boolean(favoriteStatus.value[String(script.id)]) return Boolean(favoriteStatus.value[String(script.id)])
} }
@@ -789,6 +818,19 @@ const confirmDeleteScript = async () => {
background: rgba(149, 55, 255, 0.2); 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 { .summary {
display: -webkit-box; display: -webkit-box;
margin-top: 14rpx; margin-top: 14rpx;
+242 -43
View File
@@ -130,7 +130,24 @@
/> />
<view v-else class="card-answered"> <view v-else class="card-answered">
<text v-if="msg.card?.question" class="card-question-text">{{ msg.card.question }}</text> <text v-if="msg.card?.question" class="card-question-text">{{ msg.card.question }}</text>
<text>已回答{{ msg.answer }}</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> </view>
@@ -1189,6 +1206,20 @@ const PENDING_SCRIPT_CHAT_KEY = 'pending_open_script_chat'
const createMessageId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const createMessageId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const createConversationId = () => `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const createConversationId = () => `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
/**
* 把已回答卡片的 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 getCurrentScriptId = () => String(currentResult.value?.id || '') const getCurrentScriptId = () => String(currentResult.value?.id || '')
const getCurrentConversationId = () => String(currentConversationId.value || currentResult.value?.conversationId || '') const getCurrentConversationId = () => String(currentConversationId.value || currentResult.value?.conversationId || '')
@@ -1455,45 +1486,100 @@ const openScriptChat = async (payload = {}) => {
await loadMessages() await loadMessages()
} }
// 将历史剧本数据转换为统一对话消息格式,填充 resultMessages // 从后端 messages 重建统一对话消息流,复刻生成时的 chat-bubble 展示
const novelContent = currentResult.value?.content || currentResult.value?.plotJson?.fullContent || '' // 不再重复 push wishTheme/novelContentmessages 中已包含)
const wishTheme = currentResult.value?.theme || currentResult.value?.title || '' const messagesList = messages.value || []
// 1. 用户心愿作为首条 user 消息 // 第一步:先把所有澄清问答按 question-answer 配对(按 messageOrder 升序)
if (wishTheme) { // 原因:DB 中 answer 可能先于 question 存入(messageOrder 更小),不能简单按数组顺序匹配「最后一张 card」
resultMessages.value.push({ // 策略:question 按 messageOrder 升序排序,每条 question 配对它之后第一个未分配的 answer
id: `loaded-wish-${Date.now()}`, const clarificationQuestions = messagesList
role: 'user', .filter(m => m.type === 'clarification_question')
kind: 'text', .sort((a, b) => (a.messageOrder || 0) - (b.messageOrder || 0))
content: wishTheme, const clarificationAnswers = messagesList
pending: false, .filter(m => m.type === 'clarification_answer')
time: formatMessageTime(), .sort((a, b) => (a.messageOrder || 0) - (b.messageOrder || 0))
submitted: false,
confirmed: false
})
}
// 2. 当前小说正文作为 AI novel 消息(已完成态 // 为每个 question 配对一个 answer(按 messageOrder 升序,第一个 > question.messageOrder 且未被占用的
if (novelContent) { const answerAssigned = new Set()
resultMessages.value.push({ const questionAnswerMap = new Map()
id: `loaded-novel-${Date.now()}`, 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)
}
})
// 未匹配的 answer(如 messageOrder 在所有 question 之前的预填充)不展示
// 原因:没有对应 question 的 answer 无法正确渲染(用户看不到问题,只显示答案会造成困惑)
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 || createMessageId(),
role: 'assistant', role: 'assistant',
kind: 'novel', kind: 'card',
content: novelContent, content: '',
card,
answer: answer?.content || '',
selectedValue: answer?.content || '',
pending: false, pending: false,
time: formatMessageTime(), time: formatMessageTime(),
submitted: false, submitted: true,
confirmed: false confirmed: true
}) })
} })
// 3. 对话历史中除首条外的其他消息追加到对话 // 第二步:按 messageOrder 顺序遍历消息,构建统一消息
// (首条通常是与 wishTheme 重复的用户消息,跳过避免重复) messagesList.forEach(m => {
if (messages.value.length > 0) { if (m.type === 'clarification_question') {
messages.value.slice(1).forEach(m => { const cardMsg = cardMessagesByQuestionId.get(m.id)
if (cardMsg) resultMessages.value.push(cardMsg)
} else if (m.type === 'clarification_answer') {
// 已配对到 question 的 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 = {} }
resultMessages.value.push({ resultMessages.value.push({
id: m.id || createMessageId(), id: m.id || createMessageId(),
role: m.sender === 'user' ? 'user' : 'assistant', role: 'assistant',
kind: 'outline',
content: '',
outline,
pending: false,
time: formatMessageTime(),
submitted: false,
confirmed: true
})
} else if (m.type === 'script') {
resultMessages.value.push({
id: m.id || createMessageId(),
role: 'assistant',
kind: 'novel',
content: m.content || '',
pending: false,
time: formatMessageTime(),
submitted: false,
confirmed: true
})
} else if (m.type === 'chat' && m.sender === 'user') {
resultMessages.value.push({
id: m.id || createMessageId(),
role: 'user',
kind: 'text', kind: 'text',
content: m.content || '', content: m.content || '',
pending: false, pending: false,
@@ -1501,12 +1587,20 @@ const openScriptChat = async (payload = {}) => {
submitted: false, submitted: false,
confirmed: false confirmed: false
}) })
}) }
// 跳过 system 类型(不展示)
})
// 调整顺序:用户心愿应该在最前面(DB 中 messageOrder 可能靠后,但逻辑上是第一条)
const userWishIndex = resultMessages.value.findIndex(m => m.role === 'user' && m.kind === 'text')
if (userWishIndex > 0) {
const [userWish] = resultMessages.value.splice(userWishIndex, 1)
resultMessages.value.unshift(userWish)
} }
analytics.track('script_chat_open_from_library', { analytics.track('script_chat_open_from_library', {
script_id: script.id, script_id: script.id,
has_history: messages.value.length > 0 has_history: messagesList.length > 0
}, { eventType: 'script', pagePath }) }, { eventType: 'script', pagePath })
scrollResultToLatest() scrollResultToLatest()
} }
@@ -1649,6 +1743,19 @@ const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme }
novelSessionId.value = '' novelSessionId.value = ''
firstQuery.value = text // 记住首次心愿,用于后续 followup 保存剧本 firstQuery.value = text // 记住首次心愿,用于后续 followup 保存剧本
console.log('[ScriptView] 设置 firstQuery:', { length: text.length }) console.log('[ScriptView] 设置 firstQuery:', { length: text.length })
// 把原始心愿保存到 store,供 followup 时透传给后端(避免 originalQuery 丢失导致不保存)
if (store.setSessionMeta) {
store.setSessionMeta({
sessionId: '', // 后续在 status 事件中更新
scriptId: '',
conversationId: '',
originalQuery: text,
style: 'career',
length: 'short'
})
}
pendingNextResponse.value = true // 标记等待下一个 assistant 响应 pendingNextResponse.value = true // 标记等待下一个 assistant 响应
resumeableSessionId.value = '' resumeableSessionId.value = ''
outlineFeedback.value = '' outlineFeedback.value = ''
@@ -1689,13 +1796,23 @@ const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme }
} }
} }
const handleShortNovelEvent = (event) => { const handleShortNovelEvent = async (event) => {
const { type, session_id, payload = {} } = event const { type, session_id, payload = {} } = event
const timestamp = Date.now() const timestamp = Date.now()
console.log('[ScriptView] 收到事件:', { type, session_id, timestamp }) console.log('[ScriptView] 收到事件:', { type, session_id, timestamp })
if (session_id) novelSessionId.value = session_id if (session_id) {
novelSessionId.value = session_id
// 把外部 short-novel-service 返回的 session_id 同步到 store,供 followup 透传
if (store.setSessionMeta && store.getSessionMeta) {
const meta = store.getSessionMeta()
if (meta) {
meta.sessionId = session_id
store.setSessionMeta(meta)
}
}
}
switch (type) { switch (type) {
case 'status': case 'status':
@@ -1751,8 +1868,25 @@ const handleShortNovelEvent = (event) => {
generationStatus.value = 'idle' generationStatus.value = 'idle'
pendingNextResponse.value = false pendingNextResponse.value = false
persistResultMessages() persistResultMessages()
// 从后端刷新剧本列表,确保新生成的短篇出现在历史中(非阻塞)
store.fetchScripts() // 把 scriptId/conversationId 同步到 store,便于详情页/历史页查最新剧本
if (store.setSessionMeta && store.getSessionMeta) {
const meta = store.getSessionMeta()
if (meta) {
meta.scriptId = scriptId.value
meta.conversationId = conversationId.value
store.setSessionMeta(meta)
}
}
// 可靠刷新剧本列表:使用 await 确保后端返回后再继续,避免列表中看不到最新剧本
try {
await store.fetchScripts()
console.log('[ScriptView] novel_done 后剧本列表刷新成功')
} catch (e) {
console.error('[ScriptView] novel_done 后剧本列表刷新失败:', e)
uni.showToast({ title: '列表刷新失败,请手动刷新', icon: 'none' })
}
break break
} }
case 'error': case 'error':
@@ -1772,24 +1906,32 @@ const submitClarification = (answer) => {
if (answeringClarification.value) return if (answeringClarification.value) return
// 找最后一张未提交的卡片消息(mp-weixin 模板不支持内联箭头函数传 msg,改为自己查找) // 找最后一张未提交的卡片消息(mp-weixin 模板不支持内联箭头函数传 msg,改为自己查找)
const card = [...resultMessages.value].reverse().find(m => m.kind === 'card' && !m.submitted) 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) { if (card) {
card.submitted = true card.submitted = true
card.answer = label // 显示用 label(中文);答案已在卡片中显示,无需额外用户气泡 // answer 存用户提交的 value,渲染时通过 card.options 映射成中文 label(与历史回放逻辑一致)
card.answer = answer
card.selectedValue = answer
} }
pendingNextResponse.value = true // 标记等待下一个 assistant 响应 pendingNextResponse.value = true // 标记等待下一个 assistant 响应
answeringClarification.value = true answeringClarification.value = true
// originalQuery 兜底:优先 firstQuery,缺失时从 store 获取,避免 followup 时 originalQuery 为空导致不保存
const effectiveOriginalQuery = firstQuery.value || (store.getSessionMeta && store.getSessionMeta()?.originalQuery) || ''
if (!effectiveOriginalQuery) {
uni.showToast({ title: '原始心愿已丢失,请重新开始', icon: 'none' })
answeringClarification.value = false
return
}
console.log('[ScriptView] submitClarification:', { console.log('[ScriptView] submitClarification:', {
sessionId: novelSessionId.value, sessionId: novelSessionId.value,
originalQueryLength: firstQuery.value?.length originalQueryLength: effectiveOriginalQuery.length
}) })
currentStreamTask.value = followupStream({ currentStreamTask.value = followupStream({
sessionId: novelSessionId.value, sessionId: novelSessionId.value,
action: 'answer_clarification', action: 'answer_clarification',
payload: { answer }, // 后端仍收 value payload: { answer }, // 后端仍收 value
originalQuery: firstQuery.value, // 首次心愿文本,用于后续 novel_done 保存 originalQuery: effectiveOriginalQuery, // 首次心愿文本,用于后续 novel_done 保存
onEvent: handleShortNovelEvent, onEvent: handleShortNovelEvent,
onError: (errMsg) => markGenerationFailed(errMsg) onError: (errMsg) => markGenerationFailed(errMsg)
}) })
@@ -3697,6 +3839,63 @@ onUnmounted(() => {
font-size: 26rpx; font-size: 26rpx;
padding: 16rpx 0; padding: 16rpx 0;
} }
.card-answer-row {
display: flex;
align-items: baseline;
gap: 8rpx;
margin-bottom: 12rpx;
}
.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 { .outline-title {
color: #ffffff; color: #ffffff;
+33
View File
@@ -16,6 +16,8 @@ const state = reactive({
inspirationRecommendations: [], inspirationRecommendations: [],
paths: [], paths: [],
currentPath: null, currentPath: null,
// 当前心愿实现的 session 元数据(用于 followup 时传递 originalQuery 兜底)
currentSessionMeta: null,
registrationData: { registrationData: {
nickname: '', nickname: '',
gender: '', gender: '',
@@ -264,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) => { const createScript = async (scriptData) => {
try { try {
const res = await epicScriptService.createScript(scriptData) const res = await epicScriptService.createScript(scriptData)
@@ -495,6 +525,9 @@ export const useAppStore = () => {
createScript, createScript,
updateScript, updateScript,
deleteScript, deleteScript,
setSessionMeta,
getSessionMeta,
clearSessionMeta,
fetchInspirationRecommendations, fetchInspirationRecommendations,
fetchRandomInspirations, fetchRandomInspirations,
generateScriptFromInspiration, generateScriptFromInspiration,
@@ -227,6 +227,7 @@ public class EpicScriptDialogueServiceImpl implements EpicScriptDialogueService
* @param metadata 大纲元数据(标题等) * @param metadata 大纲元数据(标题等)
* @return Map 包含 scriptId、conversationId、currentVersionMessageId * @return Map 包含 scriptId、conversationId、currentVersionMessageId
*/ */
@Transactional(rollbackFor = Exception.class)
public Map<String, String> saveNovelResult(String currentUserId, String theme, public Map<String, String> saveNovelResult(String currentUserId, String theme,
String fullText, Map<String, Object> metadata) { String fullText, Map<String, Object> metadata) {
String scriptId = snowflakeIdGenerator.nextIdAsString(); String scriptId = snowflakeIdGenerator.nextIdAsString();
@@ -240,8 +241,12 @@ public class EpicScriptDialogueServiceImpl implements EpicScriptDialogueService
script.setTitle(metadata != null && metadata.get("title") != null script.setTitle(metadata != null && metadata.get("title") != null
? (String) metadata.get("title") : "我的人生剧本"); ? (String) metadata.get("title") : "我的人生剧本");
script.setTheme(theme); script.setTheme(theme);
script.setStyle("career"); // style/length 从 metadata 传入,允许 null(保持原行为兼容),但禁止硬编码 "career"/"short"
script.setLength("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.setConversationId(conversationId);
script.setPlotIntro(""); script.setPlotIntro("");
script.setPlotTurning(""); script.setPlotTurning("");
@@ -266,10 +271,69 @@ public class EpicScriptDialogueServiceImpl implements EpicScriptDialogueService
conversation.setConversationStatus("active"); conversation.setConversationStatus("active");
conversation.setStartTime(now); conversation.setStartTime(now);
conversation.setLastActiveTime(now); conversation.setLastActiveTime(now);
conversation.setMessageCount(3); // 读取累积的中间步骤(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); conversationService.save(conversation);
// 3. 系统欢迎消息(messageOrder = 1 // 3. 系统欢迎消息(messageOrder 动态递增
Message systemWelcome = new Message(); Message systemWelcome = new Message();
systemWelcome.setId(snowflakeIdGenerator.nextIdAsString()); systemWelcome.setId(snowflakeIdGenerator.nextIdAsString());
systemWelcome.setConversationId(conversationId); systemWelcome.setConversationId(conversationId);
@@ -279,12 +343,12 @@ public class EpicScriptDialogueServiceImpl implements EpicScriptDialogueService
systemWelcome.setType("system"); systemWelcome.setType("system");
systemWelcome.setSender("system"); systemWelcome.setSender("system");
systemWelcome.setTimestamp(now); systemWelcome.setTimestamp(now);
systemWelcome.setMessageOrder(1L); systemWelcome.setMessageOrder(messageOrder++);
systemWelcome.setStatus("sent"); systemWelcome.setStatus("sent");
systemWelcome.setIsRead(1); systemWelcome.setIsRead(1);
messageService.createMessage(systemWelcome); messageService.createMessage(systemWelcome);
// 4. 用户输入消息(messageOrder = 2 // 4. 用户输入消息(messageOrder 动态递增
Message userMessage = new Message(); Message userMessage = new Message();
userMessage.setId(snowflakeIdGenerator.nextIdAsString()); userMessage.setId(snowflakeIdGenerator.nextIdAsString());
userMessage.setConversationId(conversationId); userMessage.setConversationId(conversationId);
@@ -294,12 +358,12 @@ public class EpicScriptDialogueServiceImpl implements EpicScriptDialogueService
userMessage.setType("chat"); userMessage.setType("chat");
userMessage.setSender("user"); userMessage.setSender("user");
userMessage.setTimestamp(now); userMessage.setTimestamp(now);
userMessage.setMessageOrder(2L); userMessage.setMessageOrder(messageOrder++);
userMessage.setStatus("sent"); userMessage.setStatus("sent");
userMessage.setIsRead(1); userMessage.setIsRead(1);
messageService.createMessage(userMessage); messageService.createMessage(userMessage);
// 5. AI 小说全文消息(messageOrder = 3 // 5. AI 小说全文消息(messageOrder 动态递增
Message aiMessage = new Message(); Message aiMessage = new Message();
aiMessage.setId(snowflakeIdGenerator.nextIdAsString()); aiMessage.setId(snowflakeIdGenerator.nextIdAsString());
aiMessage.setConversationId(conversationId); aiMessage.setConversationId(conversationId);
@@ -309,7 +373,7 @@ public class EpicScriptDialogueServiceImpl implements EpicScriptDialogueService
aiMessage.setType("script"); aiMessage.setType("script");
aiMessage.setSender("assistant"); aiMessage.setSender("assistant");
aiMessage.setTimestamp(now); aiMessage.setTimestamp(now);
aiMessage.setMessageOrder(3L); aiMessage.setMessageOrder(messageOrder++);
aiMessage.setVersionNumber(1); aiMessage.setVersionNumber(1);
aiMessage.setStatus("sent"); aiMessage.setStatus("sent");
aiMessage.setMetadata(metadata != null ? JSON.toJSONString(metadata) : "{}"); aiMessage.setMetadata(metadata != null ? JSON.toJSONString(metadata) : "{}");
@@ -791,21 +791,24 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
* 副作用:创建新 conversation,回填 script.conversationId,更新 script * 副作用:创建新 conversation,回填 script.conversationId,更新 script
*/ */
private void ensureConversationForScript(EpicScript script, String currentUserId) { private void ensureConversationForScript(EpicScript script, String currentUserId) {
// 修复:列表查询是读路径,禁止在读路径上触发数据库写操作(隐性写)
// 历史剧本兼容补建 conversation 的逻辑应放在独立的修复脚本/管理接口中,而不是每次列表查询都触发
// 这里只做检查和日志,不做任何写入
String conversationId = script.getConversationId(); String conversationId = script.getConversationId();
if (!org.springframework.util.StringUtils.hasText(conversationId)) { if (!org.springframework.util.StringUtils.hasText(conversationId)) {
// conversationId 字段本身为空,直接创建 log.debug("[EpicScript] 剧本缺少 conversationId: scriptId={}", script.getId());
createAndAttachConversation(script, currentUserId);
return; return;
} }
Conversation existing = conversationService.getById(conversationId); Conversation existing = conversationService.getById(conversationId);
if (existing != null) { if (existing == null) {
return; // 已存在,无需处理 log.warn("[EpicScript] 剧本 conversationId 指向不存在的 conversation: scriptId={}, conversationId={}",
script.getId(), conversationId);
} }
// 存在 conversationId 但 conversation 不存在(历史数据),创建新的
createAndAttachConversation(script, currentUserId);
} }
private void createAndAttachConversation(EpicScript script, String currentUserId) { private void createAndAttachConversation(EpicScript script, String currentUserId) {
// 修复:保留方法签名但不自动调用(避免读路径隐性写)
// 如需补建 conversation,请显式调用此方法(如:剧本详情页、单条修复脚本等)
Conversation conversation = new Conversation(); Conversation conversation = new Conversation();
conversation.setUserId(currentUserId); conversation.setUserId(currentUserId);
conversation.setScriptId(script.getId()); conversation.setScriptId(script.getId());
@@ -42,6 +42,101 @@ public class ShortNovelServiceImpl implements ShortNovelService {
private static final Logger log = LoggerFactory.getLogger(ShortNovelServiceImpl.class); private static final Logger log = LoggerFactory.getLogger(ShortNovelServiceImpl.class);
private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool(); 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 @Autowired
private ShortNovelConfig config; private ShortNovelConfig config;
@@ -84,7 +179,8 @@ public class ShortNovelServiceImpl implements ShortNovelService {
upstreamBody.put("message_id", "web_" + System.currentTimeMillis()); upstreamBody.put("message_id", "web_" + System.currentTimeMillis());
upstreamBody.put("query", request.getQuery()); upstreamBody.put("query", request.getQuery());
return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId, request.getQuery()); return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId,
request.getQuery(), null);
} }
@Override @Override
@@ -94,6 +190,34 @@ public class ShortNovelServiceImpl implements ShortNovelService {
throw new BusinessException("用户未登录"); 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<>(); Map<String, Object> upstreamBody = new HashMap<>();
upstreamBody.put("session_id", request.getSessionId()); upstreamBody.put("session_id", request.getSessionId());
upstreamBody.put("user_id", currentUserId); upstreamBody.put("user_id", currentUserId);
@@ -101,7 +225,8 @@ public class ShortNovelServiceImpl implements ShortNovelService {
upstreamBody.put("action", request.getAction()); upstreamBody.put("action", request.getAction());
upstreamBody.put("payload", request.getPayload()); upstreamBody.put("payload", request.getPayload());
return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId, request.getOriginalQuery()); return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId,
effectiveOriginalQuery, request.getSessionId());
} }
/** /**
@@ -112,12 +237,26 @@ public class ShortNovelServiceImpl implements ShortNovelService {
* @param currentUserId 当前用户ID(从主线程捕获,避免 ThreadLocal 在异步线程中失效) * @param currentUserId 当前用户ID(从主线程捕获,避免 ThreadLocal 在异步线程中失效)
* @param originalQuery 原始用户查询(首次请求时传入,用于保存) * @param originalQuery 原始用户查询(首次请求时传入,用于保存)
*/ */
private SseEmitter forwardSse(String path, Map<String, Object> body, String currentUserId, String originalQuery) { private SseEmitter forwardSse(String path, Map<String, Object> body, String currentUserId,
String originalQuery, String knownSessionId) {
SseEmitter emitter = new SseEmitter(config.getReadTimeout().longValue()); SseEmitter emitter = new SseEmitter(config.getReadTimeout().longValue());
EXECUTOR.execute(() -> { EXECUTOR.execute(() -> {
String url = config.getApiBaseUrl() + path; 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 { try {
// 构建 OkHttp 请求 // 构建 OkHttp 请求
Request okhttpRequest = new Request.Builder() Request okhttpRequest = new Request.Builder()
@@ -168,6 +307,58 @@ public class ShortNovelServiceImpl implements ShortNovelService {
long eventTimestamp = System.currentTimeMillis(); long eventTimestamp = System.currentTimeMillis();
log.info("[ShortNovel SSE] 处理事件: type={}, timestamp={}", type, eventTimestamp); log.info("[ShortNovel SSE] 处理事件: type={}, timestamp={}", type, eventTimestamp);
// 拦截 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 事件,保存到数据库 // 拦截 novel_done 事件,保存到数据库
if ("novel_done".equals(type)) { if ("novel_done".equals(type)) {
JSONObject payload = event.getJSONObject("payload"); JSONObject payload = event.getJSONObject("payload");
@@ -175,11 +366,33 @@ public class ShortNovelServiceImpl implements ShortNovelService {
originalQuery, payload != null); originalQuery, payload != null);
if (payload != null && originalQuery != null && !originalQuery.trim().isEmpty()) { if (payload != null && originalQuery != null && !originalQuery.trim().isEmpty()) {
String fullText = payload.getString("full_text"); String fullText = payload.getString("full_text");
if (fullText != null) { // 兜底:如果上游 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<>(); Map<String, Object> metadata = new HashMap<>();
if (payload.get("title") != null) { if (payload.get("title") != null) {
metadata.put("title", payload.get("title")); metadata.put("title", payload.get("title"));
} }
// 把整个 payload(包含 outline、chapters 等)保存到 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={}", log.info("[ShortNovel SSE] 开始保存小说: userId={}, queryLength={}, textLength={}",
currentUserId, originalQuery.length(), fullText.length()); currentUserId, originalQuery.length(), fullText.length());
Map<String, String> saveResult = epicScriptDialogueServiceImpl.saveNovelResult( Map<String, String> saveResult = epicScriptDialogueServiceImpl.saveNovelResult(
@@ -0,0 +1,75 @@
package com.emotion.service.impl;
import com.alibaba.fastjson2.JSONObject;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* ShortNovelServiceImpl 静态缓存累积器单元测试
*
* @author huazhongmin
* @date 2026-07-26
*/
public class ShortNovelServiceImplTest {
@Test
public void testAccumulateStage_singleEntry() {
String sessionId = "test-session-1";
JSONObject entry = new JSONObject();
entry.put("kind", "clarification_question");
entry.put("card", new JSONObject().fluentPut("question", "你的咖啡馆特色?"));
ShortNovelServiceImpl.accumulateStage(sessionId, entry);
List<JSONObject> stages = ShortNovelServiceImpl.drainStages(sessionId);
assertEquals(1, stages.size());
assertEquals("clarification_question", stages.get(0).getString("kind"));
}
@Test
public void testAccumulateStage_multipleEntries_preservesOrder() {
String sessionId = "test-session-2";
ShortNovelServiceImpl.accumulateStage(sessionId,
new JSONObject().fluentPut("kind", "clarification_question").fluentPut("question", "Q1"));
ShortNovelServiceImpl.accumulateStage(sessionId,
new JSONObject().fluentPut("kind", "clarification_answer").fluentPut("answer", "A1"));
ShortNovelServiceImpl.accumulateStage(sessionId,
new JSONObject().fluentPut("kind", "outline").fluentPut("outline", new JSONObject()));
List<JSONObject> stages = ShortNovelServiceImpl.drainStages(sessionId);
assertEquals(3, stages.size());
assertEquals("clarification_question", stages.get(0).getString("kind"));
assertEquals("clarification_answer", stages.get(1).getString("kind"));
assertEquals("outline", stages.get(2).getString("kind"));
}
@Test
public void testDrainStages_clearsCache() {
String sessionId = "test-session-3";
ShortNovelServiceImpl.accumulateStage(sessionId,
new JSONObject().fluentPut("kind", "outline"));
List<JSONObject> first = ShortNovelServiceImpl.drainStages(sessionId);
assertEquals(1, first.size());
// 二次读取应为空(drain 后已清理)
List<JSONObject> second = ShortNovelServiceImpl.drainStages(sessionId);
assertTrue(second.isEmpty());
}
@Test
public void testAccumulateStage_nullSessionId_ignored() {
ShortNovelServiceImpl.accumulateStage(null,
new JSONObject().fluentPut("kind", "outline"));
assertTrue(ShortNovelServiceImpl.drainStages(null).isEmpty());
}
@Test
public void testDrainStages_unknownSession_returnsEmpty() {
List<JSONObject> stages = ShortNovelServiceImpl.drainStages("nonexistent-session");
assertTrue(stages.isEmpty());
}
}