Files
2026-05-27 19:43:15 +08:00

847 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 前端意图识别与交互集成 — 实现指南
> 对应后端系统:`backend/app/intent/` + `backend/app/orchestrator.py`
> 前端主文件:`SixStepChatPage.tsx`, `api.ts`, `types.ts`, `SixStepConversation.tsx`
> 完成度:**全部 6 步 + follow_up ✅**
---
## 一、核心概念回顾
### 意图体系(Intent System)
前端负责**传递上下文** + **处理 SSE 事件流** + **更新 UI 状态**,后端负责**识别意图** + **分支路由** + **执行业务**。
```
前端按需收集当前状态信息
↓
打包到 IntentOrchestrateRequest
↓
POST /orchestrate-stream (SSE)
↓
后端 detect_intent() 识别意图
↓
根据意图分支,emit SSE 事件
↓
前端处理事件,同步更新 message 和 phase
```
### 七种意图与前端对应
| 意图 | 触发词 | 后端处理 | 前端转换 | 最终状态 |
|------|-------|---------|---------|---------|
| **find_expert** | "找专家" | find_expert MCP | phase: idle → expert-match | 显示专家卡片 + MCP 选择弹窗 |
| **reselect_expert** | "换专家" | find_expert MCP + 过滤 | phase: expert-confirm → expert-match | 显示新的专家卡片 |
| **start_analysis** | "分析" "开始" | 全新 6bf | phase: expert-confirm → generate | 显示 6bf 阶段卡片和流程图 |
| **restart_analysis** | "重新分析" | 清空+重新执行 | phase: completed → generate | 同上 |
| **continue_analysis** | "继续" (在 paused 时) | 从中断点恢复 | phase: paused → generate | 显示 resume 提示 + 继续的卡片 |
| **follow_up** | "为什么" "解释" (在 completed 时) | LLM 回答 | phase: completed → follow-up | Markdown 渲染追问回答 |
| **unknown** | 含糊/歧义 | 请求澄清 | phase: * → clarifying | 显示澄清问题 |
---
## 二、数据模型层(types.ts)
### 2.1 SixStepPhase 枚举
```typescript
export type SixStepPhase =
| 'idle' // 【初始】未开始任何操作
| 'intent-detecting' // 【过渡】意图正在识别中(通常很短,用户看不到)
| 'expert-match' // 【等待】展示专家卡片,用户可浏览、搜索、重选
| 'expert-confirm' // 【交互】MCP 多选弹窗打开,用户确认选择
| 'generate' // 【处理】6bf 编排执行中,显示进度和卡片
| 'paused' // 【中断】用户手动停止;可继续或重新分析
| 'completed' // 【完成】编排全部完成;可追问或重新分析
| 'clarifying' // 【澄清】后端无法理解,请用户补充
| 'follow-up'; // 【追问】正在基于已有分析回答用户问题
```
**关键点**:
- `expert-match` vs `expert-confirm` 的区别:前者是**显示结果**,后者是**交互确认**(MCP 弹窗)
- `paused` **必须**在 `handleStop()` 手动设置,后端的 continue_analysis 分支不会主动发送 paused
- `follow-up` 仅在 `current_phase === 'completed'` 且用户输入匹配追问模式时触发
### 2.2 SixStepMessage 新增字段
```typescript
export interface SixStepMessage {
// ... 原有字段 (id, role, content, timestamp, stages, finalAnswer, ...)
// ========== 意图识别相关 ==========
intent?: {
name: string; // 识别的意图名(find_expert / follow_up / unknown 等)
confidence?: number; // 置信度 [0.0, 1.0]
reason?: string; // 识别理由(用于 debug/日志)
};
// ========== 澄清问题 ==========
clarifyQuestion?: string; // 后端提出的澄清问题(当 intent.name === "unknown")
// ========== 继续分析上下文 ==========
/**
* 原始步骤数据(来自后端 step_end 事件)。
* 结构:BackendStepResult[] = { key, name, status, thinking, structured_outputs, markdown, references }
* 用途:
* - 当用户暂停(handleStop)时,保存这些数据
* - 当用户"继续分析"时,传回后端作为 last_steps,后端可恢复上下文
* - 当用户"追问"时,后端用这些数据构造 context,生成更准确的回答
*/
rawSteps?: BackendStepResult[];
/**
* 本轮是从哪个步骤继续的(来自后端 resume 事件)。
* 值:step key(如 "behavior_factor")或 step name(如 "预期行为与驱动因素")
* UI 用途:在 AssistantMessage 上方显示"⏸ 从 [步骤名] 继续"提示
*/
resumedFrom?: string;
}
```
### 2.3 IntentOrchestrateRequest(api.ts)
```typescript
export interface IntentOrchestrateRequest {
// 意图识别相关
intent_mode?: 'auto' | 'force_orchestrate' | 'force_find_expert';
// 会话状态(前端告知后端)
current_phase: string;
selected_expert?: ExpertCard | null;
selected_mcps?: SelectableMcpKey[];
// 继续分析相关
last_steps?: BackendStepResult[];
resume_from_step?: string;
expert_candidates?: ExpertCard[]; // 用于 reselect_expert 时过滤
}
```
---
## 三、流处理层(api.ts)
### 3.1 runIntentOrchestrateStream() — 统一 SSE 处理器
```typescript
export async function runIntentOrchestrateStream(
message: string,
options: {
onUpdate?: (msg: SixStepMessage) => void;
onComplete?: () => void;
abortSignal?: AbortSignal;
},
requestBody?: IntentOrchestrateRequest
): Promise<SixStepStreamController>
```
**内部逻辑概述**:
```
1. 发起 POST /orchestrate-stream(带 requestBody 和 AbortSignal)
2. 遍历 SSE 事件流,解析 JSON
3. 根据事件名分发到不同处理函数
4. 每次修改 message 后调用 onUpdate(message),触发前端 re-render
5. 接收 done 或 error 事件时停止
```
### 3.2 关键事件处理函数
#### **intent 事件**
```typescript
// 来自后端:{ intent: "follow_up", confidence: 0.88, reason: "..." }
case 'intent':
const intentName = data.intent;
message.intent = { name: intentName, ...data };
// 👇 根据意图确定流程分支
if (intentName === 'find_expert' || intentName === 'reselect_expert') {
flow = 'expert'; // 接下来期待 expert_start/expert_end
} else if (intentName === 'follow_up') {
flow = 'follow_up'; // 接下来期待 delta(文本块)
message.phase = 'follow-up';
message.outputFormat = 'markdown';
} else if (intentName === 'unknown') {
flow = 'clarify'; // 接下来期待 clarify 事件
} else {
// start_analysis / restart_analysis / continue_analysis
flow = 'generate'; // 接下来期待 start / step_start / step_end
}
break;
```
**前端的 flow 变量**(内部状态,不持久化):用于判断预期的**下一个事件类型**和**数据处理方式**。
#### **expert_start / expert_end 事件**
```typescript
case 'expert_start':
message.phase = 'expert-match';
message.expertMatch = {
status: 'searching',
thinking: '',
experts: [],
selectedExpertId: undefined,
};
break;
case 'expert_end':
// data = { experts: [...], thinking: str, is_reselect: bool, ... }
message.expertMatch!.status = 'ready';
message.expertMatch!.thinking = data.thinking;
// 逐张卡片追加,配合 pop 动画
for (const expert of data.experts) {
message.expertMatch!.experts.push({
id: expert.id,
name: expert.name,
detail: expert.detail,
...
});
// 每张卡片之间延迟 100ms,触发 pop 动画
}
// 卡片全部加载后,更新 phase 并打开 MCP 选择弹窗
message.phase = 'expert-confirm';
// → SixStepChatPage 监听 expertMatch.status === 'ready',打开 mcpModalOpen
break;
```
#### **clarify 事件**
```typescript
case 'clarify':
message.phase = 'clarifying';
message.clarifyQuestion = data.message; // "我没有完全理解你的意图,请补充说明..."
break;
```
#### **resume 事件**(continue_analysis 特有)
```typescript
case 'resume':
// data = { task_id, resume_from, mcp_list: [...] }
message.resumedFrom = data.resume_from; // 后端告诉前端从哪个步骤继续
// UI 用途:在 AssistantMessage 顶部显示 "⏸ 从 [步骤名] 继续"
// 该变量会保留在消息中,整个 continue 过程结束后仍显示
break;
```
#### **start 事件**(开始 6bf 编排)
```typescript
case 'start':
message.phase = 'generate';
// data = { task_id, mcp_list: [...], ts: number }
// 👇 防止 restart_analysis 时保留旧卡片
if (!isResume) { // isResume 由 resume 事件设置,continue_analysis 时为 true
message.stages = {}; // 清空
message.subStepStatus = {};
message.mainStepStatus = { info: 'done', sixstep: 'pending', final: 'pending' };
}
// 若 isResume === true(continue_analysis),则保留已有的 stages,只追加新步骤
message.currentMainStep = 'sixstep';
break;
```
**关键**:`!isResume` 的条件确保 **restart_analysis 时清空旧卡片**,而 **continue_analysis 时保留已有卡片**。
#### **step_start / step_end 事件**
```typescript
case 'step_start':
// data = { name: "目的分析", key: "purpose" }
const stepKey = data.key;
if (stepKey === 'purpose') {
message.subStepStatus['purpose'] = 'running';
message.currentSubStep = 'purpose';
message.stages!.purpose = { thinking: '', cards: [], complete: false };
}
// ... 类似处理 actor / behavior / narrative
break;
case 'step_end':
// data = { key, status, thinking, structured_outputs, markdown, references, ... }
const stepKey = data.key;
// 👇 原始数据存档,用于后续 continue_analysis / follow_up
if (stepKey !== 'info_prepare') {
message.rawSteps = message.rawSteps || [];
message.rawSteps.push(data); // 保存完整的 BackendStepResult
}
// 更新当前消息的对应阶段
if (stepKey === 'purpose') {
message.stages!.purpose!.thinking = data.thinking;
message.stages!.purpose!.cards = buildPurposeCards(data.structured_outputs);
message.stages!.purpose!.complete = true;
message.subStepStatus['purpose'] = 'done';
}
// ... 类似处理 actor / behavior / narrative
break;
```
#### **delta 事件**(follow_up 专有)
```typescript
case 'delta':
// data = { text: "..." }(文本块)
// 仅当 flow === 'follow_up' 时处理
if (flow === 'follow_up') {
message.markdownContent = (message.markdownContent || '') + data.text;
onUpdate(message); // 逐块更新前端,流式显示
}
break;
```
#### **done 事件**
```typescript
case 'done':
// 各分支的终止信号
if (flow === 'follow_up') {
message.phase = 'completed'; // ✅ follow_up 回答完成
} else if (flow === 'expert') {
// 专家查询完成,等待前端用户在弹窗中确认选择
// phase 保持 'expert-confirm'
} else if (flow === 'generate') {
message.phase = 'completed'; // ✅ 6bf 推演完成
} else if (flow === 'clarify') {
// 保持 'clarifying',等待用户重新输入
}
onComplete?.(); // 回调:请求完成
break;
```
---
## 四、主页面逻辑(SixStepChatPage.tsx)
### 4.1 新增状态变量
```typescript
// 当前阶段(驱动 UI 和后端意图识别)
const [currentPhase, setCurrentPhase] = useState<SixStepPhase>('idle');
// 已选专家和 MCP(用于请求体和 UI 展示)
const [selectedExpert, setSelectedExpert] = useState<ExpertCard | null>(null);
const [selectedMcps, setSelectedMcps] = useState<SelectableMcpKey[]>([]);
// 已推荐过的专家列表(reselect_expert 时用于过滤)
const [expertCandidates, setExpertCandidates] = useState<ExpertCard[]>([]);
// 临时保存原始用户输入(确认专家后需要)
const [lastUserInput, setLastUserInput] = useState<string>('');
```
### 4.2 handleSend() — 请求体构造
```typescript
async function handleSend(inputText: string) {
setLastUserInput(inputText); // 保存原始输入
// 【1】获取上次的助手消息(用于获取 rawSteps)
const lastAssistant = messages.findLast(m => m.role === 'assistant');
// 【2】构造请求体(关键!传递上下文给后端)
const requestBody: IntentOrchestrateRequest = {
intent_mode: 'auto', // 自动意图识别
current_phase: currentPhase,
selected_expert: selectedExpert,
selected_mcps: selectedMcps,
last_steps: lastAssistant?.rawSteps || [], // 👈 用于 continue / follow_up
resume_from_step: undefined, // 让后端自动推断
expert_candidates: expertCandidates, // 👈 用于 reselect_expert 过滤
};
// 【3】调用流处理
const controller = await runIntentOrchestrateStream(
inputText,
{
onUpdate: (msg) => {
// 替换消息列表中的末尾消息(正在生成的消息)
setMessages(prev => {
const newMessages = [...prev];
newMessages[newMessages.length - 1] = msg;
return newMessages;
});
// 同步更新当前阶段
setCurrentPhase(msg.phase || 'idle');
// 【关键】监听专家卡片加载完成,打开 MCP 选择弹窗
if (msg.expertMatch?.status === 'ready') {
setMcpModalOpen(true);
}
},
onComplete: () => {
setIsGenerating(false);
},
},
requestBody
);
controllerRef.current = controller;
setIsGenerating(true);
}
```
**核心三步**:
1. **构造请求体**:携带所有必要的上下文(current_phase, last_steps, expert_candidates)
2. **调用流处理**:传入 onUpdate 回调,每个 SSE 事件都更新前端
3. **监听事件**:特别是 expertMatch.status === 'ready',自动打开 MCP 弹窗
### 4.3 handleMcpModalConfirm() — 确认专家后立即启动分析
```typescript
function handleMcpModalConfirm(selectedExpert: ExpertCard, selectedMcpList: SelectableMcpKey[]) {
// 【1】保存用户选择
setSelectedExpert(selectedExpert);
setSelectedMcps(selectedMcpList);
setExpertCandidates(prev => [...prev, selectedExpert]); // 添加到候选列表
// 【2】立即用 force_orchestrate 启动分析(不需要等用户再次输入)
const requestBody: IntentOrchestrateRequest = {
intent_mode: 'force_orchestrate', // 👈 强制执行编排,不识别意图
current_phase: 'expert-confirm',
selected_expert: selectedExpert,
selected_mcps: selectedMcpList,
last_steps: [],
};
// 【3】使用保存的原始输入(用户之前在输入框里输入的文本)
runIntentOrchestrateStream(
lastUserInput, // 👈 原始输入
{
onUpdate: (msg) => {
setMessages(prev => [...prev.slice(0, -1), msg]);
setCurrentPhase(msg.phase || 'idle');
},
onComplete: () => setIsGenerating(false),
},
requestBody
);
}
```
**关键点**:
- `intent_mode: 'force_orchestrate'` — 跳过意图识别,直接执行 6bf
- 使用 `lastUserInput`(保存的原始输入)而非动态输入
- 添加 selectedExpert 到 expertCandidates,以便后续 reselect 时过滤
### 4.4 handleRefetchExpert() — 重选专家
```typescript
function handleRefetchExpert() {
const requestBody: IntentOrchestrateRequest = {
intent_mode: 'force_find_expert', // 👈 强制查询专家
current_phase: 'expert-confirm',
expert_candidates: expertCandidates, // 👈 告诉后端已有候选,避免重复推荐
last_steps: [],
};
runIntentOrchestrateStream(
lastUserInput,
{
onUpdate: (msg) => {
setMessages(prev => [...prev.slice(0, -1), msg]);
setCurrentPhase(msg.phase || 'idle');
// MCP 弹窗保持打开,等待用户重新选择
},
},
requestBody
);
}
```
### 4.5 handleStop() — 停止生成
```typescript
function handleStop() {
controllerRef.current?.stop();
// 👇 关键:标记当前消息为 paused,供后续 continue_analysis 识别
setMessages(prev => {
const newMessages = [...prev];
const lastMsg = newMessages[newMessages.length - 1];
if (lastMsg?.role === 'assistant') {
lastMsg.phase = 'paused';
}
return newMessages;
});
setCurrentPhase('paused');
setIsGenerating(false);
}
```
**为什么需要标记 paused?**
- 后端的 intent 识别器会检查 `current_phase === 'paused'`,优先返回 `continue_analysis` 意图
- 前端显示 "⏸ 从 [步骤] 继续" 提示,让用户清楚地知道可以继续
---
## 五、对话区渲染(SixStepConversation.tsx)
### 5.1 AssistantMessage 的新渲染逻辑
```typescript
const AssistantMessage = ({ message, active }: Props) => {
const isExpertPhase = message.phase === 'expert-match' || message.phase === 'expert-confirm';
const isFollowUp = message.phase === 'follow-up';
const isClarifying = message.phase === 'clarifying';
const isMarkdown = message.outputFormat === 'markdown';
// 👇 【防护条件】防止旧分析内容在专家/澄清/追问阶段出现
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
return (
<div>
{/* 【分支 1】专家匹配阶段 */}
{isExpertPhase && message.expertMatch && (
<ExpertMatchCards
data={message.expertMatch}
onRefetch={onRefetch}
onConfirm={onConfirm}
/>
)}
{/* 【分支 2】6bf 分析阶段(防护条件) */}
{showAnalysisContent && (
<>
{/* 继续分析提示 */}
{message.resumedFrom && (
<div className="resume-hint">
⏸ 从「{message.resumedFrom}」继续
</div>
)}
{/* 各阶段卡片 */}
{message.stages?.info && <InfoStage ... />}
{message.stages?.purpose && <PurposeStage ... />}
{message.stages?.actor && <ActorStage ... />}
{message.stages?.behavior && <BehaviorStage ... />}
{message.stages?.narrative && <NarrativeStage ... />}
{message.finalAnswer && <FinalAnswer ... />}
</>
)}
{/* 【分支 3】追问回答(Markdown) */}
{isFollowUp && message.markdownContent && (
<MarkdownRenderer content={message.markdownContent} />
)}
{/* 【分支 4】Markdown 输出格式 */}
{isMarkdown && !isFollowUp && message.markdownContent && (
<MarkdownRenderer content={message.markdownContent} />
)}
{/* 【分支 5】澄清问题 */}
{isClarifying && message.clarifyQuestion && (
<div className="clarify-block">
<p className="text-sm text-gray-600">我需要你进一步说明:</p>
<p className="font-medium">{message.clarifyQuestion}</p>
<p className="text-xs text-gray-500 mt-2">请重新输入以补充信息</p>
</div>
)}
</div>
);
};
```
**关键防护条件**:
```typescript
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
```
这一行确保了:
- ✅ `expert-match` 阶段只显示专家卡片,不显示旧分析
- ✅ `clarifying` 阶段只显示澄清问题,不显示其他内容
- ✅ `follow-up` 阶段只显示 Markdown 回答,不显示旧分析
### 5.2 阶段加载动画
```typescript
// 当前阶段正在生成时,显示 shimmer(流光)动画
const purposeGenerating = message.phase === 'generate' &&
message.currentSubStep === 'purpose' &&
active;
<StageHeader
loading={purposeGenerating}
icon={<Target ... />}
title="目的分析"
count={message.stages?.purpose?.cards.length || 0}
/>
```
---
## 六、生命周期示例
### 6.1 用户"找专家"的流程
```
【用户输入】"帮我找个专家"
↓
【前端】handleSend("帮我找个专家")
- requestBody: { intent_mode: 'auto', current_phase: 'idle', last_steps: [] }
- 调用 runIntentOrchestrateStream
↓
【后端】detect_intent("帮我找个专家", current_phase='idle', ...)
- 规则识别:匹配 _FIND_PATTERNS → confidence=0.88
- 返回:IntentResult(intent='find_expert', ...)
↓
【后端】根据 intent='find_expert',调用 _stream_find_expert()
- emit: expert_start
- 查询 find_expert MCP agent
- emit: expert_end { experts: [...] }
- emit: done
↓
【前端】处理 SSE 事件
- intent: { name: 'find_expert', ... }
→ flow = 'expert'
- expert_start
→ message.phase = 'expert-match'
→ message.expertMatch.status = 'searching'
- expert_end
→ message.expertMatch.status = 'ready'
→ message.expertMatch.experts = [...]
→ message.phase = 'expert-confirm'
→ 检测到 expertMatch.status === 'ready'
→ setMcpModalOpen(true) 打开 MCP 选择弹窗
- done
→ onComplete()
↓
【用户】在 MCP 选择弹窗中选择专家和 MCP,点击"确认"
↓
【前端】handleMcpModalConfirm(selectedExpert, selectedMcps)
- setSelectedExpert(selectedExpert)
- setSelectedMcps(selectedMcps)
- requestBody: { intent_mode: 'force_orchestrate', ... }
- 调用 runIntentOrchestrateStream(lastUserInput, ...)
↓
【后端】orchestrate_stream() with intent_mode='force_orchestrate'
- 跳过意图识别
- 调用 _stream_orchestrate(..., intent_name='start_analysis')
- emit: start
- emit: step_start / step_end × 4 (purpose / actor / behavior / narrative)
- emit: flow { nodes, edges }
- emit: done
↓
【前端】处理 SSE 事件
- start
→ message.phase = 'generate'
→ 初始化 stages 和状态
- step_start × 4
→ 每个步骤的 status = 'running'
→ 对应阶段初始化为空
- step_end × 4
→ 填充 stages[stepKey].cards
→ message.rawSteps.push(data)
→ status = 'done'
- flow
→ 流程图数据更新(供沙箱显示)
- done
→ message.phase = 'completed'
↓
【前端 UI】
- ✅ 显示 4 个阶段的卡片
- ✅ 显示流程图沙箱
- ✅ 阶段完成时有 pop 动画和叙事卡片
```
### 6.2 用户"继续分析"的流程
```
【前次】完成了目的、行为体阶段,用户点击"停止"
- 后端返回 step_end(预期行为/驱动因素)但用户取消
- message.phase = 'paused'
- message.rawSteps = [
{ key: 'purpose', status: 'completed', ... },
{ key: 'actor', status: 'completed', ... },
{ key: 'behavior_factor', status: 'running', ... }
]
↓
【用户输入】"继续"
↓
【前端】handleSend("继续")
- requestBody: {
intent_mode: 'auto',
current_phase: 'paused', // 👈 关键
last_steps: message.rawSteps, // 👈 关键
}
↓
【后端】detect_intent("继续", current_phase='paused', last_steps=[...])
- 规则识别:匹配 _CONTINUE_PATTERNS AND current_phase='paused'
- 返回:IntentResult(intent='continue_analysis', confidence=0.90)
↓
【后端】_stream_orchestrate(..., intent_name='continue_analysis')
- _resume_context_from_steps(req.last_steps, None)
→ 检查已完成的步骤:['purpose', 'actor']
→ resume_key = 'behavior_factor'(下一个未完成的)
- emit: resume { resume_from: 'behavior_factor', mcp_list: ['behavior_factor', 'narrative'] }
- emit: start { mcp_list: ['behavior_factor', 'narrative'] }
- emit: step_start / step_end × 2 (behavior_factor, narrative)
- emit: done
↓
【前端】处理 SSE 事件
- intent: { name: 'continue_analysis', ... }
- resume: { resume_from: 'behavior_factor' }
→ message.resumedFrom = 'behavior_factor'
→ isResume = true // 标记为继续,不清空旧 stages
- start
→ 因为 isResume=true,不清空 message.stages
→ 保留已有的 purpose/actor 卡片
- step_start / step_end × 2
→ 追加新的 behavior/narrative 卡片
→ message.rawSteps.push(behavior_data)
→ message.rawSteps.push(narrative_data)
- done
→ message.phase = 'completed'
↓
【前端 UI】
- ✅ 显示"⏸ 从「预期行为与驱动因素」继续"提示
- ✅ 保留旧的目的/行为体卡片
- ✅ 追加新的预期行为/叙事卡片
```
### 6.3 用户"追问"的流程
```
【前次】完成了完整的 6bf,message.phase = 'completed'
- message.rawSteps = [
{ key: 'purpose', ... },
{ key: 'actor', ... },
{ key: 'behavior_factor', ... },
{ key: 'narrative', ... }
]
↓
【用户输入】"第一条是什么意思"
↓
【前端】handleSend("第一条是什么意思")
- requestBody: {
intent_mode: 'auto',
current_phase: 'completed', // 👈 关键
last_steps: message.rawSteps,
}
↓
【后端】detect_intent("第一条是什么意思", current_phase='completed', last_steps=[...])
- 规则识别:匹配 _FOLLOW_UP_PATTERNS(r"第[一二三四五六七八九十\d]+[点条个项]")
- 返回:IntentResult(intent='follow_up', confidence=0.88)
↓
【后端】_stream_follow_up(req, ...)
- 提取 req.last_steps 中已完成步骤的数据
- 构造 context_str(包含目的、行为体、预期行为、叙事)
- 调用 LLM 流式回答
- emit: delta { text: "..." } × N
- emit: done
↓
【前端】处理 SSE 事件
- intent: { name: 'follow_up', ... }
→ flow = 'follow_up'
→ message.phase = 'follow-up'
→ message.outputFormat = 'markdown'
- delta × N
→ message.markdownContent += text
→ 每块更新都触发 onUpdate,前端流式显示
- done
→ message.phase = 'completed'
↓
【前端 UI】
- ✅ 显示 Markdown 格式的追问回答
- ✅ 不显示旧的 6bf 卡片(因为 showAnalysisContent = false)
- ✅ 流式效果:用户看到实时生成的文本
```
---
## 七、常见问题解决
### Q1: 完成 6bf 后再选专家,为什么显示了旧的分析内容?
**原因**:AssistantMessage 中没有正确应用 `showAnalysisContent` 防护条件。
**检查**:
```typescript
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
```
确保:
- `isExpertPhase = message.phase === 'expert-match' || message.phase === 'expert-confirm'` ✓
- 所有 `stages` / `finalAnswer` 的渲染都被 `{showAnalysisContent && (...)}` 包裹 ✓
### Q2: 继续分析后,旧卡片消失了
**原因**:SSE start 事件中清空了 stages(应该只在 !isResume 时清空)。
**检查** `api.ts` 中的 start 事件处理:
```typescript
case 'start':
if (!isResume) { // 👈 这个条件必须有
message.stages = {};
}
// ...
break;
```
### Q3: 用户点"继续",但后端返回了"开始新分析"
**原因**:`current_phase` 未传递为 'paused',或未手动设置 `phase = 'paused'`。
**检查** `SixStepChatPage.handleStop()`:
```typescript
setMessages(prev => {
const newMessages = [...prev];
const lastMsg = newMessages[newMessages.length - 1];
if (lastMsg?.role === 'assistant') {
lastMsg.phase = 'paused'; // 👈 必须设置
}
return newMessages;
});
setCurrentPhase('paused'); // 👈 必须同步状态
```
### Q4: 追问回答为空
**原因**:
1. `current_phase !== 'completed'`(后端不识别为 follow_up)
2. `last_steps` 为空或格式错误
3. LLM 调用失败(查看后端日志)
**检查**:
- 确保 message.rawSteps 在 step_end 时被正确赋值
- 确保 request 中 `last_steps` 被传递为 `message.rawSteps`
- 后端日志是否有 `_stream_follow_up` 的报错
---
## 八、开发建议
1. **调试技巧**:在 `onUpdate` 中 `console.log(message)` 观察每个事件的状态变化
2. **SSE 事件顺序不可打破**:前端严格依赖事件顺序做状态机转换
3. **rawSteps 的完整性**:非 info_prepare 的所有 step_end 都必须存档,否则 continue/follow_up 会丢失数据
4. **phase 与 outputFormat 的对应**:
- follow-up → outputFormat = 'markdown'
- generate → outputFormat = 'structured'(卡片渲染)
- expert-match → 无 outputFormat(专家卡片独立)
---
**相关文件导航**:
- 后端意图系统:`backend/app/intent/`
- 后端编排系统:`backend/app/orchestrator.py`
- 前端核心:`SixStepChatPage.tsx` (state hub) → `api.ts` (SSE handler) → `SixStepConversation.tsx` (rendering)
- 前端类型:`types.ts`
- 前端原始文档:`doc.md`