docs:ScriptView 统一对话页改造实现计划
6 个任务:扩展消息模型、handleShortNovelEvent 改对话流、 runGeneration+交互函数改造、统一 chat 视图模板、 sendChat 接口切换、端到端浏览器验证
This commit is contained in:
@@ -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 消息并调 followupStream;novel_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 后底部输入栏出现,继续修改用旧接口工作
|
||||
- [ ] 失败时对话流内显示"继续创作"入口
|
||||
- [ ] 整个过程不切换视图,用户始终在同一对话页
|
||||
Reference in New Issue
Block a user