Files
deerflow-code/web-code/components/angentchat/INTENT_INTEGRATION.md
T
2026-05-27 19:43:15 +08:00

28 KiB
Raw Blame History

前端意图识别与交互集成 — 实现指南

对应后端系统: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 枚举

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 新增字段

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)

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 处理器

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 事件

// 来自后端:{ 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 事件

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 事件

case 'clarify':
  message.phase = 'clarifying';
  message.clarifyQuestion = data.message;  // "我没有完全理解你的意图,请补充说明..."
  break;

resume 事件(continue_analysis 特有)

case 'resume':
  // data = { task_id, resume_from, mcp_list: [...] }
  message.resumedFrom = data.resume_from;  // 后端告诉前端从哪个步骤继续
  
  // UI 用途:在 AssistantMessage 顶部显示 "⏸ 从 [步骤名] 继续"
  // 该变量会保留在消息中,整个 continue 过程结束后仍显示
  break;

start 事件(开始 6bf 编排)

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 事件

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 专有)

case 'delta':
  // data = { text: "..." }(文本块)
  // 仅当 flow === 'follow_up' 时处理
  
  if (flow === 'follow_up') {
    message.markdownContent = (message.markdownContent || '') + data.text;
    onUpdate(message);  // 逐块更新前端,流式显示
  }
  break;

done 事件

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 新增状态变量

// 当前阶段(驱动 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() — 请求体构造

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() — 确认专家后立即启动分析

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() — 重选专家

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() — 停止生成

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 的新渲染逻辑

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>
  );
};

关键防护条件:

const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;

这一行确保了:

  • ✅ expert-match 阶段只显示专家卡片,不显示旧分析
  • ✅ clarifying 阶段只显示澄清问题,不显示其他内容
  • ✅ follow-up 阶段只显示 Markdown 回答,不显示旧分析

5.2 阶段加载动画

// 当前阶段正在生成时,显示 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 防护条件。

检查:

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 事件处理:

case 'start':
  if (!isResume) {  // 👈 这个条件必须有
    message.stages = {};
  }
  // ...
  break;

Q3: 用户点"继续",但后端返回了"开始新分析"

原因:current_phase 未传递为 'paused',或未手动设置 phase = 'paused'。

检查 SixStepChatPage.handleStop():

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