847 lines
28 KiB
Markdown
847 lines
28 KiB
Markdown
# 前端意图识别与交互集成 — 实现指南
|
||
|
||
> 对应后端系统:`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`
|