1359 lines
54 KiB
TypeScript
1359 lines
54 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { AlignLeft, GitBranch, History, LayoutList, Sparkles } from 'lucide-react';
|
||
|
||
import { useTheme } from '../../contexts/ThemeContext';
|
||
import ChatInputComposer from '../agent/ChatInputComposer';
|
||
import { runExpertCreate, runExpertSearch, runSixStepDeduction, runIntentOrchestrateStream, type ExpertRunController, type IntentOrchestrateRequest } from './api';
|
||
import McpSelectModal from './McpSelectModal';
|
||
import SixStepConversation from './SixStepConversation';
|
||
import SixStepChatHistory, { type SixStepConversationMeta } from './SixStepChatHistory';
|
||
import SixStepFlowFullscreen from './SixStepFlowFullscreen';
|
||
import SixStepFlowSandbox from './SixStepFlowSandbox';
|
||
import SixStepProgressHorizontal from './SixStepProgressHorizontal';
|
||
import { buildInitialAssistantMessage, type SixStepMockController } from './mockData';
|
||
import './styles.css';
|
||
import type {
|
||
ExpertCard,
|
||
ExpertMatchData,
|
||
MainStepKey,
|
||
SelectableMcpKey,
|
||
SixStepMessage,
|
||
StepStatus,
|
||
SubStepKey,
|
||
} from './types';
|
||
|
||
/**
|
||
* ===========================================================================
|
||
* SixStepChatPage —— 六步法(6bf)推演问答主界面
|
||
* ===========================================================================
|
||
*
|
||
* 职责概览:
|
||
* - 管理多会话列表与当前激活会话,并与后端 `/api/conversations` 做增删改查同步。
|
||
* - 将用户输入与「上一条助手消息」中的会话态(`phase`、`selectedExpert`、`rawSteps` 等)
|
||
* 组装为 `IntentOrchestrateRequest`,通过 `runIntentOrchestrateStream` 消费 SSE,
|
||
* 持续合并到当前助手 `SixStepMessage`(意图识别、专家匹配、六步编排、追问 Markdown 等)。
|
||
* - 专家流程:列表内操作经 `expertHandlers` 回到本页;用户在 `McpSelectModal` 选定子链后,
|
||
* 以 `intent_mode: 'force_orchestrate'` 立即启动编排,无需再次输入。
|
||
* - 停止:`handleStop` 中止流式控制器,并把生成中的助手消息标为 `paused`,
|
||
* 便于后端根据 `last_steps` / `current_phase` 做继续分析等分支。
|
||
*
|
||
* 主流程(用户发送一条消息):
|
||
* 1. `handleSend`:校验输入 → 无会话则 `createConversationOnServer`。
|
||
* 2. 追加 user 消息 + 新的 assistant 占位(`buildInitialAssistantMessage`,初始 `intent-detecting`)。
|
||
* 3. 从最后一条 assistant 推导 `current_phase`、`last_steps`(暂停续跑场景)等,构造 `reqBody`。
|
||
* 4. `runIntentOrchestrateStream`:`onUpdate` 按消息 id 合并助手状态;`onComplete` 持久化对话。
|
||
*
|
||
* 专家相关分支:
|
||
* - 后端下发 `expert_*` 事件 → 助手进入 `expert-match` / `expert-confirm`(由 `api.ts` 内处理)。
|
||
* - `handleRefetchExpert`:`intent_mode: 'force_find_expert'`,并带上已有 `expert_candidates`。
|
||
* - `handleMcpModalConfirm`:写入已选专家与子链,再 `force_orchestrate` 跑六步。
|
||
*
|
||
* 下方「模块级」函数只做类型与 API 载荷转换;组件内 `useCallback` 承载业务编排。
|
||
* ===========================================================================
|
||
*/
|
||
|
||
/** 前端内存中的单条会话:在通用 meta 上扩展创建时间与完整消息列表。 */
|
||
interface SixStepConversationEntry extends SixStepConversationMeta {
|
||
createdAt: number;
|
||
model?: string;
|
||
messages: SixStepMessage[];
|
||
}
|
||
|
||
/** 后端 `/api/conversations` 中单条消息的存储形态:`data` 承载 SixStep 扩展字段。 */
|
||
interface ConversationApiMessage {
|
||
id: string;
|
||
role: 'user' | 'assistant';
|
||
content: string;
|
||
created_at?: number;
|
||
data?: Partial<SixStepMessage>;
|
||
}
|
||
|
||
/** 后端返回的单条会话记录(含 messages 数组)。 */
|
||
interface ConversationApiRecord {
|
||
id: string;
|
||
filter_key: string;
|
||
filter_value: string;
|
||
title: string;
|
||
model?: string | null;
|
||
meta?: Record<string, unknown>;
|
||
created_at: number;
|
||
updated_at: number;
|
||
messages: ConversationApiMessage[];
|
||
}
|
||
|
||
interface ModelOption {
|
||
id: string;
|
||
name: string;
|
||
provider: string;
|
||
enabled?: boolean;
|
||
}
|
||
|
||
interface ModelsResponse {
|
||
default_model_id: string;
|
||
models: ModelOption[];
|
||
}
|
||
|
||
/** 后端对话列表筛选键:区分不同页面写入的会话数据。 */
|
||
const FILTER_KEY = 'page';
|
||
/** 与 `FILTER_KEY` 成对使用,本页固定为 sixstep 聊天场景。 */
|
||
const FILTER_VALUE = 'sixstep-chat';
|
||
/** 新建会话时的占位标题,首条用户消息后会用摘要替换。 */
|
||
const NEW_TITLE = '新对话';
|
||
|
||
/** 助手消息缺少 `mainStepStatus` 时的默认进度占位(与 `SixStepMessage` 结构对齐)。 */
|
||
const DEFAULT_MAIN_STATUS: Record<MainStepKey, StepStatus> = {
|
||
info: 'pending',
|
||
sixstep: 'pending',
|
||
final: 'pending',
|
||
};
|
||
|
||
/** 六步子步骤默认进度(purpose / actor / behavior / narrative)。 */
|
||
const DEFAULT_SUB_STATUS: Record<SubStepKey, StepStatus> = {
|
||
purpose: 'pending',
|
||
actor: 'pending',
|
||
behavior: 'pending',
|
||
narrative: 'pending',
|
||
};
|
||
|
||
/** 右侧流程图沙箱可拖拽宽度区间(像素)。 */
|
||
const MIN_SANDBOX_WIDTH = 360;
|
||
const MAX_SANDBOX_WIDTH = 900;
|
||
/** 沙箱初始宽度。 */
|
||
const DEFAULT_SANDBOX_WIDTH = 520;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 模块级工具:会话排序、时间戳、与后端 Conversation API 的序列化/反序列化
|
||
// ---------------------------------------------------------------------------
|
||
const backUrl = 'https://ch1.b.uat.4.cn/zncmback';
|
||
/** 按 `updatedAt` 降序排列,保证侧边历史列表「最近在上」。 */
|
||
const sortConversations = (items: SixStepConversationEntry[]) =>
|
||
[...items].sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0));
|
||
|
||
/** 将 API 或持久化里混用的日期类型统一为 `Date`,解析失败则回退为当前时间。 */
|
||
const toTimestamp = (value?: Date | number | string): Date => {
|
||
if (value instanceof Date) {
|
||
return value;
|
||
}
|
||
if (typeof value === 'number') {
|
||
return new Date(value);
|
||
}
|
||
if (typeof value === 'string' && value) {
|
||
const date = new Date(value);
|
||
if (!Number.isNaN(date.getTime())) {
|
||
return date;
|
||
}
|
||
}
|
||
return new Date();
|
||
};
|
||
|
||
/** 将单条 API 消息(`data` 内嵌 SixStep 扩展字段)还原为前端 `SixStepMessage`。 */
|
||
const normalizeMessage = (item: ConversationApiMessage): SixStepMessage => {
|
||
const payload = item.data || {};
|
||
return {
|
||
...payload,
|
||
id: item.id,
|
||
role: item.role,
|
||
content: item.content ?? payload.content ?? '',
|
||
timestamp: toTimestamp(payload.timestamp ?? item.created_at),
|
||
} as SixStepMessage;
|
||
};
|
||
|
||
/** 将后端完整会话记录转为带 `messages: SixStepMessage[]` 的前端会话条目。 */
|
||
const normalizeConversation = (item: ConversationApiRecord): SixStepConversationEntry => ({
|
||
id: item.id,
|
||
title: item.title || NEW_TITLE,
|
||
createdAt: Number(item.created_at || Date.now()),
|
||
updatedAt: Number(item.updated_at || Date.now()),
|
||
model: item.model || '',
|
||
messages: (item.messages || []).map(normalizeMessage),
|
||
});
|
||
|
||
/**
|
||
* 持久化单条消息:`data` 中存放完整 SixStep 状态,便于刷新后恢复卡片/阶段/rawSteps。
|
||
* `created_at` 使用消息时间戳的毫秒值,供列表排序。
|
||
*/
|
||
const serializeMessage = (message: SixStepMessage) => ({
|
||
id: message.id,
|
||
role: message.role,
|
||
content: message.content || '',
|
||
created_at: message.timestamp instanceof Date ? message.timestamp.getTime() : Date.now(),
|
||
data: {
|
||
...message,
|
||
timestamp: message.timestamp instanceof Date ? message.timestamp.toISOString() : new Date().toISOString(),
|
||
},
|
||
});
|
||
|
||
/** 拼接带 `filter_key` / `filter_value` 查询参数的会话 REST URL。 */
|
||
const conversationRequestUrl = (conversationId?: string) => {
|
||
const search = new URLSearchParams({
|
||
filter_key: FILTER_KEY,
|
||
filter_value: FILTER_VALUE,
|
||
});
|
||
const base = conversationId ? `/api/conversations/${conversationId}` : '/api/conversations';
|
||
return `${backUrl}${base}?${search.toString()}`;
|
||
};
|
||
|
||
/**
|
||
* 六步法推演页组件:对话列表 + 消息区 + 输入区 + 可选流程图沙箱/全屏 + MCP 选择弹窗。
|
||
* 所有与后端的流式编排交互的入口集中在 `handleSend`、`handleRefetchExpert`、`handleMcpModalConfirm`。
|
||
*/
|
||
export default function SixStepChatPage() {
|
||
const { theme } = useTheme();
|
||
const isDark = theme === 'dark';
|
||
|
||
// ----- 会话与消息(核心状态)-----
|
||
const [conversations, setConversations] = useState<SixStepConversationEntry[]>([]);
|
||
const [activeConvId, setActiveConvId] = useState<string | null>(null);
|
||
const [input, setInput] = useState('');
|
||
/** 是否有流式任务在跑(意图流、专家创建、旧版推演等共用)。 */
|
||
const [isGenerating, setIsGenerating] = useState(false);
|
||
/** 编排请求体 `output_format`:结构化卡片 vs Markdown(影响关闭流程图侧栏等行为)。 */
|
||
const [outputFormat, setOutputFormat] = useState<'structured' | 'markdown'>('structured');
|
||
/** 模型切换:模型列表、全局默认模型、当前页面选中模型。 */
|
||
const [models, setModels] = useState<ModelOption[]>([]);
|
||
const [globalDefaultModel, setGlobalDefaultModel] = useState('');
|
||
const [selectedModel, setSelectedModel] = useState('');
|
||
|
||
/** 切换输出格式;Markdown 模式下关闭流程图沙箱与全屏,避免与纯文展示冲突。 */
|
||
const handleSetOutputFormat = useCallback((fmt: 'structured' | 'markdown') => {
|
||
setOutputFormat(fmt);
|
||
if (fmt === 'markdown') {
|
||
setSandboxOpen(false);
|
||
setFullscreenOpen(false);
|
||
}
|
||
}, []);
|
||
|
||
// ----- 布局与 UI 附属状态 -----
|
||
const [sandboxOpen, setSandboxOpen] = useState(false);
|
||
const [sandboxWidth, setSandboxWidth] = useState(DEFAULT_SANDBOX_WIDTH);
|
||
const [fullscreenOpen, setFullscreenOpen] = useState(false);
|
||
const [historyOpen, setHistoryOpen] = useState(false);
|
||
/** 首次从服务端拉取会话列表完成前为 true,用于禁用输入等。 */
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
// ----- Refs:流式控制器、布局、与异步闭包中读取的最新 conversations -----
|
||
/** 当前主编排/意图流控制器(`runIntentOrchestrateStream` 或 `runSixStepDeduction`)。 */
|
||
const controllerRef = useRef<SixStepMockController | null>(null);
|
||
/** 专家搜索/创建等独立流(与主控制器分离,便于分别 stop)。 */
|
||
const expertControllerRef = useRef<ExpertRunController | null>(null);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
/** 流程图沙箱宽度拖拽时的指针起点与初始宽度。 */
|
||
const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
||
/** 避免首屏重复请求会话列表。 */
|
||
const hasLoadedRef = useRef(false);
|
||
/** 与 `conversations` state 同步,供不依赖 conversations 的闭包读取最新列表。 */
|
||
const conversationsRef = useRef<SixStepConversationEntry[]>([]);
|
||
|
||
/**
|
||
* 当前「正在交互」的助手消息 id(专家卡片高亮、重搜专家等)。
|
||
* 若未显式设置,则回退为会话中最后一条 assistant(见下方 `activeAssistantId` 计算)。
|
||
*/
|
||
const [activeAssistantMsgId, setActiveAssistantMsgId] = useState<string | null>(null);
|
||
/** MCP 子链选择弹窗:挂在某条助手消息上,并持有待确认的专家卡片。 */
|
||
const [mcpModal, setMcpModal] = useState<{ open: boolean; messageId: string | null; expert: ExpertCard | null }>(
|
||
{ open: false, messageId: null, expert: null },
|
||
);
|
||
|
||
/** 当前选中的完整会话实体;无选中时为 null。 */
|
||
const activeConv = useMemo(
|
||
() => conversations.find((conversation) => conversation.id === activeConvId) || null,
|
||
[activeConvId, conversations],
|
||
);
|
||
/** 当前会话下的消息列表(仅渲染用,写入请通过 `setMessages` / `patchMessageById` / `upsertConversationState`)。 */
|
||
const messages = activeConv?.messages ?? [];
|
||
|
||
/** 侧边历史抽屉所需的轻量元数据(已按更新时间排序)。 */
|
||
const conversationMetas = useMemo<SixStepConversationMeta[]>(
|
||
() =>
|
||
sortConversations(conversations).map(({ id, title, updatedAt }) => ({
|
||
id,
|
||
title,
|
||
updatedAt,
|
||
})),
|
||
[conversations],
|
||
);
|
||
|
||
/**
|
||
* 顶部进度条与流程图沙箱所绑定的「当前助手消息」id:
|
||
* 优先 `activeAssistantMsgId`(若仍存在于当前会话);否则取最后一条 assistant。
|
||
*/
|
||
const activeAssistantId = (() => {
|
||
if (activeAssistantMsgId && messages.some((message) => message.id === activeAssistantMsgId)) {
|
||
return activeAssistantMsgId;
|
||
}
|
||
const lastAssistant = [...messages].reverse().find((message) => message.role === 'assistant');
|
||
return lastAssistant?.id ?? null;
|
||
})();
|
||
|
||
/** 绑定到顶部进度条、流程图沙箱/全屏的助手消息;无消息时为 undefined。 */
|
||
const activeAssistant = messages.find((message) => message.id === activeAssistantId);
|
||
/** 主步骤状态(信息准备 / 六步 / 最终回答)用于 `SixStepProgressHorizontal`。 */
|
||
const liveMainStatus = activeAssistant?.mainStepStatus ?? DEFAULT_MAIN_STATUS;
|
||
/** 子步骤(purpose / actor / behavior / narrative)进度展示。 */
|
||
const liveSubStatus = activeAssistant?.subStepStatus ?? DEFAULT_SUB_STATUS;
|
||
|
||
useEffect(() => {
|
||
conversationsRef.current = conversations;
|
||
}, [conversations]);
|
||
|
||
/** 中止所有流式任务并清理控制器引用;不修改消息内容(停止后的 UI 由 `handleStop` 负责)。 */
|
||
const stopGeneration = useCallback(() => {
|
||
controllerRef.current?.stop();
|
||
controllerRef.current = null;
|
||
expertControllerRef.current?.stop();
|
||
expertControllerRef.current = null;
|
||
setIsGenerating(false);
|
||
}, []);
|
||
|
||
/**
|
||
* 在指定会话内按消息 id 做一次不可变更新(`patch` 返回完整新消息对象)。
|
||
* 用于专家卡片局部更新等「只改一条消息」的场景。
|
||
*/
|
||
const patchMessageById = useCallback(
|
||
(conversationId: string, messageId: string, patch: (msg: SixStepMessage) => SixStepMessage) => {
|
||
setConversations((prev) =>
|
||
prev.map((conversation) => {
|
||
if (conversation.id !== conversationId) return conversation;
|
||
return {
|
||
...conversation,
|
||
messages: conversation.messages.map((m) => (m.id === messageId ? patch(m) : m)),
|
||
updatedAt: Date.now(),
|
||
};
|
||
}),
|
||
);
|
||
},
|
||
[],
|
||
);
|
||
|
||
/** GET 列表:拉取本页 `filter_key` / `filter_value` 下的全部会话并排序。 */
|
||
const fetchConversations = useCallback(async (): Promise<SixStepConversationEntry[]> => {
|
||
const response = await fetch(conversationRequestUrl());
|
||
if (!response.ok) {
|
||
throw new Error(`加载聊天记录失败:${response.status}`);
|
||
}
|
||
const payload = (await response.json()) as ConversationApiRecord[];
|
||
return sortConversations(payload.map(normalizeConversation));
|
||
}, []);
|
||
|
||
/** GET 模型列表:用于页面头部模型切换。 */
|
||
const fetchModels = useCallback(async (): Promise<ModelsResponse> => {
|
||
const response = await fetch(`${backUrl}/api/models`);
|
||
if (!response.ok) {
|
||
throw new Error(`加载模型列表失败:${response.status}`);
|
||
}
|
||
return (await response.json()) as ModelsResponse;
|
||
}, []);
|
||
|
||
/** POST 创建空会话(可带初始 messages),返回规范化后的会话条目。 */
|
||
const createConversationOnServer = useCallback(
|
||
async (
|
||
title: string,
|
||
model?: string,
|
||
messagesToSave: SixStepMessage[] = [],
|
||
): Promise<SixStepConversationEntry> => {
|
||
const response = await fetch(`${backUrl}/api/conversations`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
filter_key: FILTER_KEY,
|
||
filter_value: FILTER_VALUE,
|
||
title,
|
||
model,
|
||
meta: { page: FILTER_VALUE },
|
||
messages: messagesToSave.map(serializeMessage),
|
||
}),
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`创建对话失败:${response.status}`);
|
||
}
|
||
return normalizeConversation((await response.json()) as ConversationApiRecord);
|
||
},
|
||
[],
|
||
);
|
||
|
||
/** PUT 更新会话:标题、模型、meta 或整表 messages(由调用方决定传哪些字段)。 */
|
||
const updateConversationOnServer = useCallback(
|
||
async (
|
||
conversationId: string,
|
||
payload: {
|
||
title?: string;
|
||
model?: string;
|
||
meta?: Record<string, unknown>;
|
||
messages?: SixStepMessage[];
|
||
},
|
||
): Promise<SixStepConversationEntry> => {
|
||
const response = await fetch(conversationRequestUrl(conversationId), {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
title: payload.title,
|
||
model: payload.model,
|
||
meta: payload.meta,
|
||
messages: payload.messages?.map(serializeMessage),
|
||
}),
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`更新对话失败:${response.status}`);
|
||
}
|
||
return normalizeConversation((await response.json()) as ConversationApiRecord);
|
||
},
|
||
[],
|
||
);
|
||
|
||
/** DELETE 单条会话。 */
|
||
const deleteConversationOnServer = useCallback(async (conversationId: string) => {
|
||
const response = await fetch(conversationRequestUrl(conversationId), {
|
||
method: 'DELETE',
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`删除对话失败:${response.status}`);
|
||
}
|
||
}, []);
|
||
|
||
/** DELETE 当前筛选条件下的全部会话(清空历史)。 */
|
||
const clearConversationsOnServer = useCallback(async () => {
|
||
const response = await fetch(conversationRequestUrl(), {
|
||
method: 'DELETE',
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`清空聊天记录失败:${response.status}`);
|
||
}
|
||
}, []);
|
||
|
||
/** 将一条会话合并进 state:同 id 覆盖,新会话插入队首后再按时间排序。 */
|
||
const upsertConversationState = useCallback((conversation: SixStepConversationEntry) => {
|
||
setConversations((prev) =>
|
||
sortConversations([conversation, ...prev.filter((item) => item.id !== conversation.id)]),
|
||
);
|
||
}, []);
|
||
|
||
/**
|
||
* 用 `conversationsRef` 中的最新快照整表 PUT 到服务端,再用返回结果更新本地。
|
||
* 流式 `onComplete`、停止后延迟保存等会调用,避免丢最后几条 SSE 合并结果。
|
||
*/
|
||
const persistConversation = useCallback(
|
||
async (conversationId: string) => {
|
||
const current = conversationsRef.current.find((item) => item.id === conversationId);
|
||
if (!current) return null;
|
||
const updated = await updateConversationOnServer(conversationId, {
|
||
title: current.title,
|
||
model: current.model,
|
||
meta: { page: FILTER_VALUE },
|
||
messages: current.messages,
|
||
});
|
||
upsertConversationState(updated);
|
||
return updated;
|
||
},
|
||
[updateConversationOnServer, upsertConversationState],
|
||
);
|
||
|
||
/**
|
||
* 仅更新「当前激活会话」的 messages,语义上类似 `useState` 的 setState,
|
||
* 但实现为在 `conversations` 数组里找到 `activeConvId` 对应项并替换其 `messages`。
|
||
*/
|
||
const setMessages = useCallback<React.Dispatch<React.SetStateAction<SixStepMessage[]>>>(
|
||
(value) => {
|
||
setConversations((prev) =>
|
||
prev.map((conversation) => {
|
||
if (conversation.id !== activeConvId) return conversation;
|
||
const nextMessages =
|
||
typeof value === 'function'
|
||
? (value as (messages: SixStepMessage[]) => SixStepMessage[])(conversation.messages)
|
||
: value;
|
||
return {
|
||
...conversation,
|
||
messages: nextMessages,
|
||
updatedAt: Date.now(),
|
||
};
|
||
}),
|
||
);
|
||
},
|
||
[activeConvId],
|
||
);
|
||
|
||
/** 挂载时拉取会话列表;成功则选中最新一条,失败则置空列表(组件卸载时忽略异步结果)。 */
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [items, modelsPayload] = await Promise.all([fetchConversations(), fetchModels()]);
|
||
if (cancelled) return;
|
||
const nextModels = modelsPayload.models || [];
|
||
const defaultModelId = modelsPayload.default_model_id || '';
|
||
setConversations(items);
|
||
setModels(nextModels);
|
||
setGlobalDefaultModel(defaultModelId);
|
||
setActiveConvId(items[0]?.id || null);
|
||
setSelectedModel(items[0]?.model || defaultModelId || nextModels[0]?.id || '');
|
||
hasLoadedRef.current = true;
|
||
} catch {
|
||
if (!cancelled) {
|
||
setConversations([]);
|
||
setModels([]);
|
||
setGlobalDefaultModel('');
|
||
setSelectedModel('');
|
||
setActiveConvId(null);
|
||
hasLoadedRef.current = true;
|
||
}
|
||
} finally {
|
||
if (!cancelled) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [fetchConversations, fetchModels]);
|
||
|
||
/** 新建会话:停流 → 服务端创建 → 切到新会话并重置专家/MCP 弹窗与输入。 */
|
||
const handleNewConversation = useCallback(async () => {
|
||
stopGeneration();
|
||
const created = await createConversationOnServer(NEW_TITLE, selectedModel || globalDefaultModel || undefined);
|
||
upsertConversationState(created);
|
||
setActiveConvId(created.id);
|
||
setSelectedModel(created.model || selectedModel || globalDefaultModel || models[0]?.id || '');
|
||
setActiveAssistantMsgId(null);
|
||
setMcpModal({ open: false, messageId: null, expert: null });
|
||
setInput('');
|
||
}, [createConversationOnServer, globalDefaultModel, models, selectedModel, stopGeneration, upsertConversationState]);
|
||
|
||
/** 切换侧边选中的会话:停流、清空专家弹窗与输入(不自动持久化,仅切本地状态)。 */
|
||
const handleSelectConversation = useCallback(
|
||
(id: string) => {
|
||
if (id === activeConvId) return;
|
||
stopGeneration();
|
||
const nextConversation = conversationsRef.current.find((item) => item.id === id) || null;
|
||
setActiveConvId(id);
|
||
setSelectedModel(nextConversation?.model || globalDefaultModel || models[0]?.id || '');
|
||
setActiveAssistantMsgId(null);
|
||
setMcpModal({ open: false, messageId: null, expert: null });
|
||
setInput('');
|
||
},
|
||
[activeConvId, globalDefaultModel, models, stopGeneration],
|
||
);
|
||
|
||
/** 选择模型后写入当前会话,后续 /api/orchestrate/stream 请求会透传该 model。 */
|
||
const handleModelChange = useCallback(
|
||
async (modelId: string) => {
|
||
setSelectedModel(modelId);
|
||
if (!activeConvId) return;
|
||
setConversations((prev) =>
|
||
prev.map((conversation) =>
|
||
conversation.id === activeConvId
|
||
? { ...conversation, model: modelId, updatedAt: Date.now() }
|
||
: conversation,
|
||
),
|
||
);
|
||
try {
|
||
const updated = await updateConversationOnServer(activeConvId, { model: modelId });
|
||
upsertConversationState(updated);
|
||
} catch {
|
||
// 本地模型仍保留,发送时继续透传 selectedModel。
|
||
}
|
||
},
|
||
[activeConvId, updateConversationOnServer, upsertConversationState],
|
||
);
|
||
|
||
/** 删除指定会话;若删的是当前会话则停流并自动选中剩余列表中的下一条(若有)。 */
|
||
const handleDeleteConversation = useCallback(
|
||
async (id: string) => {
|
||
await deleteConversationOnServer(id);
|
||
setConversations((prev) => {
|
||
const remaining = prev.filter((item) => item.id !== id);
|
||
if (id === activeConvId) {
|
||
stopGeneration();
|
||
const next = sortConversations(remaining)[0] || null;
|
||
setActiveConvId(next?.id || null);
|
||
setSelectedModel(next?.model || globalDefaultModel || models[0]?.id || '');
|
||
}
|
||
return remaining;
|
||
});
|
||
},
|
||
[activeConvId, deleteConversationOnServer, globalDefaultModel, models, stopGeneration],
|
||
);
|
||
|
||
/** 乐观更新标题后立即 PUT,再用服务端返回覆盖(避免短暂显示旧标题)。 */
|
||
const handleRenameConversation = useCallback(
|
||
async (id: string, title: string) => {
|
||
const current = conversations.find((item) => item.id === id);
|
||
if (!current) return;
|
||
const optimistic = { ...current, title, updatedAt: Date.now() };
|
||
upsertConversationState(optimistic);
|
||
const updated = await updateConversationOnServer(id, { title });
|
||
upsertConversationState(updated);
|
||
},
|
||
[conversations, updateConversationOnServer, upsertConversationState],
|
||
);
|
||
|
||
/** 清空服务端与本地的全部本会话类型记录,并取消当前选中。 */
|
||
const handleClearAllConversations = useCallback(async () => {
|
||
stopGeneration();
|
||
await clearConversationsOnServer();
|
||
setConversations([]);
|
||
setActiveConvId(null);
|
||
}, [clearConversationsOnServer, stopGeneration]);
|
||
|
||
/**
|
||
* 仅调用专家搜索 API(不经意图编排 SSE)。
|
||
* 当前主对话已统一走 `runIntentOrchestrateStream`;本函数保留为独立专家搜索能力的封装。
|
||
*/
|
||
const startExpertSearch = useCallback(
|
||
(conversationId: string, messageId: string, userInput: string, excludeIds?: string[]) => {
|
||
expertControllerRef.current?.stop();
|
||
const baseData: ExpertMatchData = {
|
||
status: 'searching',
|
||
thinking: '',
|
||
experts: [],
|
||
};
|
||
const controller = runExpertSearch(
|
||
baseData,
|
||
{ user_input: userInput, exclude_ids: excludeIds },
|
||
{
|
||
onUpdate: (data) => {
|
||
patchMessageById(conversationId, messageId, (m) => ({
|
||
...m,
|
||
expertMatch: data,
|
||
}));
|
||
},
|
||
onComplete: () => {
|
||
expertControllerRef.current = null;
|
||
setIsGenerating(false);
|
||
void persistConversation(conversationId);
|
||
},
|
||
},
|
||
);
|
||
expertControllerRef.current = controller;
|
||
void controller.promise.catch(() => {
|
||
setIsGenerating(false);
|
||
});
|
||
},
|
||
[patchMessageById, persistConversation],
|
||
);
|
||
|
||
/**
|
||
* 旧版六步推演:在已选定专家与子链后,直接 `runSixStepDeduction` 拉流。
|
||
* 与 `runIntentOrchestrateStream` + `force_orchestrate` 并存,便于迁移期或特殊入口复用。
|
||
*/
|
||
const runDeductionForMessage = useCallback(
|
||
(conversationId: string, message: SixStepMessage, userInput: string, expert: ExpertCard, selectedMcps: SelectableMcpKey[]) => {
|
||
const requestExpert = { ...expert, id: expert.backendId ?? expert.id };
|
||
setIsGenerating(true);
|
||
setActiveAssistantMsgId(message.id);
|
||
|
||
const controller = runSixStepDeduction(
|
||
{ ...message, phase: 'generate', selectedExpert: requestExpert, selectedMcps },
|
||
{
|
||
onUpdate: (updated) => {
|
||
setConversations((prev) =>
|
||
prev.map((conversation) => {
|
||
if (conversation.id !== conversationId) return conversation;
|
||
return {
|
||
...conversation,
|
||
messages: conversation.messages.map((item) =>
|
||
item.id === updated.id ? { ...item, ...updated } : item,
|
||
),
|
||
updatedAt: Date.now(),
|
||
};
|
||
}),
|
||
);
|
||
},
|
||
onComplete: () => {
|
||
setIsGenerating(false);
|
||
controllerRef.current = null;
|
||
void persistConversation(conversationId);
|
||
},
|
||
},
|
||
{
|
||
user_input: userInput,
|
||
output_format: outputFormat,
|
||
model: selectedModel || undefined,
|
||
selected_expert: requestExpert,
|
||
selected_mcps: selectedMcps,
|
||
},
|
||
);
|
||
|
||
controllerRef.current = controller;
|
||
void controller.promise.catch(() => {
|
||
setIsGenerating(false);
|
||
});
|
||
},
|
||
[outputFormat, persistConversation, selectedModel],
|
||
);
|
||
|
||
/**
|
||
* 用户点击发送后的主编排入口(意图识别 + 分支 SSE)。
|
||
*
|
||
* 流程要点:
|
||
* 1. 无当前会话则先创建服务端会话。
|
||
* 2. 从「上一条助手消息」读取 `phase`、`selectedExpert`、`expertMatch.experts`、`rawSteps` 等,
|
||
* 组装 `IntentOrchestrateRequest`,供后端 `detect_intent` 与 continue 分支使用。
|
||
* 3. 若 `phase === 'paused'`,把上一轮的 stages / rawSteps / 选中专家与子链预填到新助手占位里,
|
||
* 并把 `last_steps`、`selected_mcps` 放进请求体(仅暂停续跑场景传 `last_steps`,避免误传)。
|
||
* 4. 追加 user + assistant 后立刻 `runIntentOrchestrateStream`,用返回的 `updated` 合并同 id 助手消息。
|
||
*/
|
||
const handleSend = useCallback(async () => {
|
||
const trimmed = input.trim();
|
||
if (!trimmed || isGenerating) return;
|
||
|
||
let target = activeConv;
|
||
if (!target) {
|
||
target = await createConversationOnServer(NEW_TITLE);
|
||
upsertConversationState(target);
|
||
setActiveConvId(target.id);
|
||
}
|
||
|
||
// 意图编排上下文:来自「上一条助手消息」,与后端 OrchestrateRequest 字段对齐
|
||
const lastAssistant = [...target.messages].reverse().find((m) => m.role === 'assistant');
|
||
const currentPhase = lastAssistant?.phase ?? 'idle';
|
||
const selectedExpert = lastAssistant?.selectedExpert ?? null;
|
||
const expertCandidates = (lastAssistant?.expertMatch?.experts ?? []) as unknown as Record<string, unknown>[];
|
||
const isPaused = currentPhase === 'paused';
|
||
const lastSteps = isPaused && lastAssistant?.rawSteps
|
||
? (lastAssistant.rawSteps as unknown as Record<string, unknown>[])
|
||
: [];
|
||
const selectedExpertPayload = selectedExpert
|
||
? (selectedExpert as unknown as Record<string, unknown>)
|
||
: null;
|
||
const pausedSelectedMcps = isPaused && lastAssistant?.selectedMcps ? lastAssistant.selectedMcps : [];
|
||
|
||
// 本轮用户气泡
|
||
const userMessage: SixStepMessage = {
|
||
id: `user-${Date.now()}`,
|
||
role: 'user',
|
||
content: trimmed,
|
||
timestamp: new Date(),
|
||
};
|
||
|
||
/**
|
||
* 新的助手占位:从「意图识别中」开始。
|
||
* 若从 paused 恢复,则克隆上一轮卡片/步骤/rawSteps/已选专家子链,便于 UI 连续与后端续跑。
|
||
*/
|
||
const assistantMessage: SixStepMessage = {
|
||
...buildInitialAssistantMessage(),
|
||
outputFormat,
|
||
phase: 'intent-detecting',
|
||
...(isPaused && lastAssistant ? {
|
||
stages: { ...lastAssistant.stages },
|
||
rawSteps: lastAssistant.rawSteps ? [...lastAssistant.rawSteps] : undefined,
|
||
subStepStatus: lastAssistant.subStepStatus ? { ...lastAssistant.subStepStatus } : undefined,
|
||
mainStepStatus: lastAssistant.mainStepStatus ? { ...lastAssistant.mainStepStatus } : undefined,
|
||
selectedExpert: lastAssistant.selectedExpert,
|
||
selectedMcps: lastAssistant.selectedMcps,
|
||
} : {}),
|
||
};
|
||
|
||
// 首条真实问题时,用摘要覆盖默认标题「新对话」
|
||
const nextTitle = !target.title || target.title === NEW_TITLE ? trimmed.slice(0, 24) : target.title;
|
||
const nextConversation: SixStepConversationEntry = {
|
||
...target,
|
||
title: nextTitle,
|
||
messages: [...target.messages, userMessage, assistantMessage],
|
||
updatedAt: Date.now(),
|
||
};
|
||
|
||
upsertConversationState(nextConversation);
|
||
setActiveConvId(nextConversation.id);
|
||
setInput('');
|
||
setIsGenerating(true);
|
||
setActiveAssistantMsgId(assistantMessage.id);
|
||
setHistoryOpen(false);
|
||
|
||
const convId = nextConversation.id;
|
||
// intent_mode 默认 auto,由 api 层与后端协商;此处只传会话态供意图识别与 continue 使用
|
||
const reqBody: IntentOrchestrateRequest = {
|
||
user_input: trimmed,
|
||
output_format: outputFormat,
|
||
model: selectedModel || target.model || globalDefaultModel || undefined,
|
||
current_phase: currentPhase,
|
||
selected_expert: selectedExpertPayload,
|
||
selected_mcps: pausedSelectedMcps,
|
||
expert_candidates: expertCandidates,
|
||
last_steps: lastSteps,
|
||
};
|
||
|
||
const controller = runIntentOrchestrateStream(
|
||
assistantMessage,
|
||
{
|
||
onUpdate: (updated) => {
|
||
setConversations((prev) =>
|
||
prev.map((conv) => {
|
||
if (conv.id !== convId) return conv;
|
||
return {
|
||
...conv,
|
||
messages: conv.messages.map((m) => (m.id === updated.id ? { ...m, ...updated } : m)),
|
||
updatedAt: Date.now(),
|
||
};
|
||
}),
|
||
);
|
||
},
|
||
onComplete: () => {
|
||
setIsGenerating(false);
|
||
controllerRef.current = null;
|
||
void persistConversation(convId);
|
||
},
|
||
},
|
||
reqBody,
|
||
);
|
||
controllerRef.current = controller;
|
||
void controller.promise.catch(() => { setIsGenerating(false); });
|
||
}, [
|
||
activeConv,
|
||
createConversationOnServer,
|
||
globalDefaultModel,
|
||
input,
|
||
isGenerating,
|
||
outputFormat,
|
||
persistConversation,
|
||
selectedModel,
|
||
upsertConversationState,
|
||
]);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 专家匹配与 MCP 子链:由 SixStepConversation 经 expertHandlers 回调到本页
|
||
// 典型顺序:选专家卡片 → 确认 → McpSelectModal 选子链 → handleMcpModalConfirm 开跑编排
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 在 `conversationsRef` 全集中按消息 id 查找所属会话与消息(避免闭包拿到过期列表)。 */
|
||
const findMessage = useCallback(
|
||
(messageId: string): { conv: SixStepConversationEntry | null; msg: SixStepMessage | null } => {
|
||
for (const conv of conversationsRef.current) {
|
||
const m = conv.messages.find((x) => x.id === messageId);
|
||
if (m) return { conv, msg: m };
|
||
}
|
||
return { conv: null, msg: null };
|
||
},
|
||
[],
|
||
);
|
||
|
||
/** 用户在专家卡片上点选某张专家:只写入 `expertMatch.selectedExpertId`(尚未开始推演)。 */
|
||
const handleSelectExpert = useCallback(
|
||
(messageId: string, expertId: string) => {
|
||
const { conv } = findMessage(messageId);
|
||
if (!conv) return;
|
||
patchMessageById(conv.id, messageId, (m) => ({
|
||
...m,
|
||
expertMatch: m.expertMatch ? { ...m.expertMatch, selectedExpertId: expertId } : m.expertMatch,
|
||
}));
|
||
},
|
||
[findMessage, patchMessageById],
|
||
);
|
||
|
||
/**
|
||
* 「换一批 / 重新找专家」:用触发该助手气泡前的用户原句 + `force_find_expert`,
|
||
* 并传入已有 `expert_candidates` 供后端去重。流控制器挂在 `expertControllerRef` 以便 stop。
|
||
*/
|
||
const handleRefetchExpert = useCallback(
|
||
(messageId: string) => {
|
||
const { conv, msg } = findMessage(messageId);
|
||
if (!conv || !msg) return;
|
||
const idx = conv.messages.findIndex((m) => m.id === messageId);
|
||
const userMsg = idx > 0 ? [...conv.messages.slice(0, idx)].reverse().find((m) => m.role === 'user') : null;
|
||
const userInput = userMsg?.content?.trim() ?? '';
|
||
if (!userInput) return;
|
||
|
||
const existingCandidates = (msg.expertMatch?.experts ?? []) as unknown as Record<string, unknown>[];
|
||
|
||
// 清空上一轮专家列表与意图,界面进入「识别中」再收 SSE
|
||
patchMessageById(conv.id, messageId, (m) => ({
|
||
...m,
|
||
phase: 'intent-detecting' as const,
|
||
expertMatch: { status: 'searching' as const, thinking: '', experts: [] },
|
||
intent: undefined,
|
||
}));
|
||
setIsGenerating(true);
|
||
setActiveAssistantMsgId(messageId);
|
||
|
||
const convId = conv.id;
|
||
const refetchMsg: SixStepMessage = {
|
||
...msg,
|
||
phase: 'intent-detecting',
|
||
expertMatch: { status: 'searching', thinking: '', experts: [] },
|
||
intent: undefined,
|
||
};
|
||
|
||
const controller = runIntentOrchestrateStream(
|
||
refetchMsg,
|
||
{
|
||
onUpdate: (updated) => {
|
||
setConversations((prev) =>
|
||
prev.map((c) => {
|
||
if (c.id !== convId) return c;
|
||
return {
|
||
...c,
|
||
messages: c.messages.map((m) => (m.id === updated.id ? { ...m, ...updated } : m)),
|
||
updatedAt: Date.now(),
|
||
};
|
||
}),
|
||
);
|
||
},
|
||
onComplete: () => {
|
||
setIsGenerating(false);
|
||
controllerRef.current = null;
|
||
void persistConversation(convId);
|
||
},
|
||
},
|
||
{
|
||
user_input: userInput,
|
||
intent_mode: 'force_find_expert',
|
||
model: selectedModel || conv.model || globalDefaultModel || undefined,
|
||
expert_candidates: existingCandidates,
|
||
},
|
||
);
|
||
expertControllerRef.current = { stop: controller.stop, promise: controller.promise };
|
||
void controller.promise.catch(() => { setIsGenerating(false); });
|
||
},
|
||
[findMessage, globalDefaultModel, patchMessageById, persistConversation, selectedModel],
|
||
);
|
||
|
||
/** 调用创建专家接口,进度与结果写回同一条消息的 `expertMatch`。 */
|
||
const handleCreateExpert = useCallback(
|
||
(messageId: string) => {
|
||
const { conv, msg } = findMessage(messageId);
|
||
if (!conv || !msg) return;
|
||
const idx = conv.messages.findIndex((m) => m.id === messageId);
|
||
const userMsg = idx > 0 ? [...conv.messages.slice(0, idx)].reverse().find((m) => m.role === 'user') : null;
|
||
const userInput = userMsg?.content?.trim() || '';
|
||
if (!userInput) return;
|
||
|
||
expertControllerRef.current?.stop();
|
||
setIsGenerating(true);
|
||
setActiveAssistantMsgId(messageId);
|
||
const baseData = msg.expertMatch ?? { status: 'idle', thinking: '', experts: [] };
|
||
const controller = runExpertCreate(
|
||
baseData,
|
||
{ user_input: userInput },
|
||
{
|
||
onUpdate: (data) => {
|
||
patchMessageById(conv.id, messageId, (m) => ({ ...m, expertMatch: data }));
|
||
},
|
||
onComplete: () => {
|
||
expertControllerRef.current = null;
|
||
setIsGenerating(false);
|
||
void persistConversation(conv.id);
|
||
},
|
||
},
|
||
);
|
||
expertControllerRef.current = controller;
|
||
void controller.promise.catch(() => {
|
||
setIsGenerating(false);
|
||
});
|
||
},
|
||
[findMessage, patchMessageById, persistConversation],
|
||
);
|
||
|
||
/**
|
||
* 用户确认当前选中的专家:打开 MCP 子链选择弹窗(不立即请求编排)。
|
||
* 真正开跑在 `handleMcpModalConfirm`。
|
||
*/
|
||
const handleConfirmExpertSelect = useCallback(
|
||
(messageId: string) => {
|
||
const { msg } = findMessage(messageId);
|
||
if (!msg || !msg.expertMatch) return;
|
||
const expert = msg.expertMatch.experts.find((e) => e.id === msg.expertMatch?.selectedExpertId) || null;
|
||
if (!expert) return;
|
||
setMcpModal({ open: true, messageId, expert });
|
||
},
|
||
[findMessage],
|
||
);
|
||
|
||
/** 关闭 MCP 弹窗且不提交子链选择。 */
|
||
const handleMcpModalCancel = useCallback(() => {
|
||
setMcpModal({ open: false, messageId: null, expert: null });
|
||
}, []);
|
||
|
||
/**
|
||
* MCP 弹窗确认:把专家 + 勾选子链写入消息,并以「原始用户问题」为 `user_input`、
|
||
* `intent_mode: 'force_orchestrate'` 立即启动六步编排 SSE(不再依赖本轮意图识别)。
|
||
*
|
||
* 注意:会清空 `stages` / 最终回答等,相当于从专家确认点重新开始一条生成链路。
|
||
*/
|
||
const handleMcpModalConfirm = useCallback(
|
||
(selectedMcps: SelectableMcpKey[]) => {
|
||
const messageId = mcpModal.messageId;
|
||
const expert = mcpModal.expert;
|
||
if (!messageId || !expert) {
|
||
setMcpModal({ open: false, messageId: null, expert: null });
|
||
return;
|
||
}
|
||
const { conv, msg } = findMessage(messageId);
|
||
if (!conv || !msg) return;
|
||
|
||
// 回溯该助手消息之前的最近一条用户消息,作为编排 user_input(与首次 handleSend 的句子一致)
|
||
const msgIdx = conv.messages.findIndex((m) => m.id === messageId);
|
||
const userMsg = msgIdx > 0
|
||
? [...conv.messages.slice(0, msgIdx)].reverse().find((m) => m.role === 'user')
|
||
: null;
|
||
const originalInput = userMsg?.content?.trim() ?? '';
|
||
|
||
const requestExpert = { ...expert, id: expert.backendId ?? expert.id } as unknown as ExpertCard;
|
||
|
||
// 重置为「即将跑六步」的干净状态,避免残留上一次的 stages / 流式片段
|
||
const resetMsg: SixStepMessage = {
|
||
...msg,
|
||
phase: 'generate',
|
||
outputFormat,
|
||
selectedExpert: requestExpert,
|
||
selectedMcps,
|
||
expertMatch: msg.expertMatch
|
||
? { ...msg.expertMatch, status: 'confirmed', selectedExpertId: expert.id, selectedMcps }
|
||
: msg.expertMatch,
|
||
intent: undefined,
|
||
stages: {},
|
||
finalAnswer: '',
|
||
markdownContent: '',
|
||
mainStepStatus: { info: 'pending', sixstep: 'pending', final: 'pending' },
|
||
subStepStatus: { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' },
|
||
currentMainStep: undefined,
|
||
currentSubStep: undefined,
|
||
};
|
||
|
||
// 先同步到 state,再发起流式请求,避免首包事件到达时仍显示旧 UI
|
||
patchMessageById(conv.id, messageId, () => resetMsg);
|
||
setMcpModal({ open: false, messageId: null, expert: null });
|
||
setActiveAssistantMsgId(messageId);
|
||
setIsGenerating(true);
|
||
|
||
const convId = conv.id;
|
||
const controller = runIntentOrchestrateStream(
|
||
resetMsg,
|
||
{
|
||
onUpdate: (updated) => {
|
||
setConversations((prev) =>
|
||
prev.map((c) => {
|
||
if (c.id !== convId) return c;
|
||
return {
|
||
...c,
|
||
messages: c.messages.map((m) => (m.id === updated.id ? { ...m, ...updated } : m)),
|
||
updatedAt: Date.now(),
|
||
};
|
||
}),
|
||
);
|
||
},
|
||
onComplete: () => {
|
||
setIsGenerating(false);
|
||
controllerRef.current = null;
|
||
void persistConversation(convId);
|
||
},
|
||
},
|
||
{
|
||
user_input: originalInput,
|
||
output_format: outputFormat,
|
||
model: selectedModel || conv.model || globalDefaultModel || undefined,
|
||
intent_mode: 'force_orchestrate',
|
||
selected_expert: requestExpert as unknown as Record<string, unknown>,
|
||
selected_mcps: selectedMcps,
|
||
},
|
||
);
|
||
controllerRef.current = controller;
|
||
void controller.promise.catch(() => { setIsGenerating(false); });
|
||
// 与 onComplete 双保险:确保刚写入 resetMsg 的本地状态也被持久化(避免竞态丢保存)
|
||
window.setTimeout(() => { void persistConversation(convId); }, 0);
|
||
},
|
||
[findMessage, globalDefaultModel, mcpModal.expert, mcpModal.messageId, outputFormat, patchMessageById, persistConversation, selectedModel],
|
||
);
|
||
|
||
/**
|
||
* 用户中止生成:停止所有流式控制器,并把「未完成的助手消息」标为可续跑状态。
|
||
* - 将 `running` 的主/子步骤重置为 `pending`,清除当前指针。
|
||
* - 若正处于 `generate` 或 `intent-detecting`,将 `phase` 置为 `paused`,配合下次 `handleSend` 传 `last_steps`。
|
||
* - 异步触发一次 `persistConversation`,把 paused 态写入服务端。
|
||
*/
|
||
const handleStop = useCallback(() => {
|
||
const targetId = activeConvId;
|
||
controllerRef.current?.stop();
|
||
controllerRef.current = null;
|
||
expertControllerRef.current?.stop();
|
||
expertControllerRef.current = null;
|
||
setIsGenerating(false);
|
||
setMessages((prev) =>
|
||
prev.map((message) => {
|
||
if (message.role !== 'assistant' || message.mainStepStatus?.final === 'done') return message;
|
||
const main = { ...(message.mainStepStatus ?? DEFAULT_MAIN_STATUS) };
|
||
const sub = { ...(message.subStepStatus ?? DEFAULT_SUB_STATUS) };
|
||
(Object.keys(main) as MainStepKey[]).forEach((key) => {
|
||
if (main[key] === 'running') main[key] = 'pending';
|
||
});
|
||
(Object.keys(sub) as SubStepKey[]).forEach((key) => {
|
||
if (sub[key] === 'running') sub[key] = 'pending';
|
||
});
|
||
// 暂停态:后端 continue_analysis 依赖 current_phase 与 last_steps
|
||
const isInGenerate = message.phase === 'generate' || message.phase === 'intent-detecting';
|
||
return {
|
||
...message,
|
||
phase: isInGenerate ? ('paused' as const) : message.phase,
|
||
mainStepStatus: main,
|
||
subStepStatus: sub,
|
||
currentMainStep: undefined,
|
||
currentSubStep: undefined,
|
||
};
|
||
}),
|
||
);
|
||
if (targetId) {
|
||
window.setTimeout(() => { void persistConversation(targetId); }, 0);
|
||
}
|
||
}, [activeConvId, persistConversation, setMessages]);
|
||
|
||
/** 拖拽调整流程图沙箱宽度:在最小/最大值与主内容区剩余宽度之间取交集。 */
|
||
const handleResizeMove = useCallback((event: MouseEvent) => {
|
||
const resizeData = resizeRef.current;
|
||
const container = containerRef.current;
|
||
if (!resizeData || !container) return;
|
||
const containerRect = container.getBoundingClientRect();
|
||
const delta = resizeData.startX - event.clientX;
|
||
let next = resizeData.startWidth + delta;
|
||
next = Math.max(MIN_SANDBOX_WIDTH, Math.min(MAX_SANDBOX_WIDTH, next));
|
||
next = Math.min(next, containerRect.width - 480);
|
||
setSandboxWidth(next);
|
||
}, []);
|
||
|
||
/** 结束拖拽:恢复光标与文本选择,移除全局鼠标监听。 */
|
||
const handleResizeEnd = useCallback(() => {
|
||
resizeRef.current = null;
|
||
document.body.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
window.removeEventListener('mousemove', handleResizeMove);
|
||
window.removeEventListener('mouseup', handleResizeEnd);
|
||
}, [handleResizeMove]);
|
||
|
||
/** 开始拖拽沙箱左边界:记录初始宽度并注册 `mousemove` / `mouseup`。 */
|
||
const handleResizeStart = useCallback(
|
||
(event: React.MouseEvent) => {
|
||
event.preventDefault();
|
||
resizeRef.current = { startX: event.clientX, startWidth: sandboxWidth };
|
||
document.body.style.cursor = 'col-resize';
|
||
document.body.style.userSelect = 'none';
|
||
window.addEventListener('mousemove', handleResizeMove);
|
||
window.addEventListener('mouseup', handleResizeEnd);
|
||
},
|
||
[handleResizeEnd, handleResizeMove, sandboxWidth],
|
||
);
|
||
|
||
/** 组件卸载时:中止主编排流并清理拖拽监听,防止泄漏与幽灵请求。 */
|
||
useEffect(() => {
|
||
return () => {
|
||
controllerRef.current?.stop();
|
||
window.removeEventListener('mousemove', handleResizeMove);
|
||
window.removeEventListener('mouseup', handleResizeEnd);
|
||
};
|
||
}, [handleResizeEnd, handleResizeMove]);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 布局:顶栏(标题 + 进度 + 格式/历史/沙箱开关)| 侧栏历史 | 主列对话+输入 | 可选沙箱/全屏
|
||
// ---------------------------------------------------------------------------
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
className={`flex h-[calc(100vh-120px)] flex-col rounded-xl border theme-bg ${
|
||
isDark ? 'border-slate-700/50 bg-slate-800/50' : 'border-red-100 bg-white'
|
||
}`}
|
||
>
|
||
<div
|
||
className={`flex items-center gap-4 border-b px-6 py-4 ${
|
||
isDark ? 'border-slate-700/50' : 'border-red-100'
|
||
}`}
|
||
>
|
||
<div className="min-w-0 shrink-0">
|
||
<div className="flex items-center gap-3">
|
||
<div
|
||
className={`flex h-9 w-9 items-center justify-center rounded-2xl ${
|
||
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
|
||
}`}
|
||
>
|
||
<Sparkles className="h-5 w-5" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<h1 className={`truncate text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||
6 bf推演问答1
|
||
</h1>
|
||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||
基于 6 步法的结构化推演与可视化生成
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex min-w-0 flex-1 items-center justify-center">
|
||
<SixStepProgressHorizontal mainStatus={liveMainStatus} subStatus={liveSubStatus} isDark={isDark} />
|
||
</div>
|
||
|
||
<div className="shrink-0">
|
||
<div className="flex items-center gap-2">
|
||
<div className={`flex items-center rounded-lg border overflow-hidden ${
|
||
isDark ? 'border-slate-600/40' : 'border-gray-200'
|
||
}`}>
|
||
<div className={`px-2 py-2 text-xs ${
|
||
isDark ? 'bg-slate-700/40 text-slate-400' : 'bg-gray-50 text-gray-500'
|
||
}`}>
|
||
模型
|
||
</div>
|
||
<select
|
||
value={selectedModel || globalDefaultModel}
|
||
onChange={(e) => { void handleModelChange(e.target.value); }}
|
||
className={`h-[34px] min-w-[170px] border-0 px-2 text-xs outline-none ${
|
||
isDark ? 'bg-slate-700/40 text-slate-100' : 'bg-white text-gray-700'
|
||
}`}
|
||
title="切换当前对话使用模型"
|
||
>
|
||
{models.length === 0 ? (
|
||
<option value="">默认模型</option>
|
||
) : null}
|
||
{models.map((model) => (
|
||
<option key={model.id} value={model.id}>
|
||
{model.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Output format toggle */}
|
||
<div className={`flex items-center rounded-lg border overflow-hidden ${
|
||
isDark ? 'border-slate-600/40' : 'border-gray-200'
|
||
}`}>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSetOutputFormat('structured')}
|
||
title="结构化卡片输出"
|
||
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-all ${
|
||
outputFormat === 'structured'
|
||
? isDark
|
||
? 'bg-cyan-500/20 text-cyan-200'
|
||
: 'bg-red-50 text-red-600'
|
||
: isDark
|
||
? 'bg-slate-700/40 text-slate-400 hover:text-slate-200'
|
||
: 'bg-white text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
<LayoutList className="h-3.5 w-3.5" />
|
||
<span>结构化</span>
|
||
</button>
|
||
<div className={`w-px self-stretch ${isDark ? 'bg-slate-600/40' : 'bg-gray-200'}`} />
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSetOutputFormat('markdown')}
|
||
title="Markdown 文本输出"
|
||
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-all ${
|
||
outputFormat === 'markdown'
|
||
? isDark
|
||
? 'bg-cyan-500/20 text-cyan-200'
|
||
: 'bg-red-50 text-red-600'
|
||
: isDark
|
||
? 'bg-slate-700/40 text-slate-400 hover:text-slate-200'
|
||
: 'bg-white text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
<AlignLeft className="h-3.5 w-3.5" />
|
||
<span>Markdown</span>
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setHistoryOpen((value) => !value)}
|
||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-all ${
|
||
historyOpen
|
||
? isDark
|
||
? 'border-cyan-400/40 bg-cyan-500/15 text-cyan-200'
|
||
: 'border-red-200 bg-red-50 text-red-600'
|
||
: isDark
|
||
? 'border-slate-600/40 bg-slate-700/40 text-slate-300 hover:bg-slate-700'
|
||
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
<History className="h-4 w-4" />
|
||
<span>{historyOpen ? '关闭记录' : '聊天记录'}</span>
|
||
</button>
|
||
|
||
{outputFormat === 'structured' && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSandboxOpen((value) => !value)}
|
||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-all ${
|
||
sandboxOpen
|
||
? isDark
|
||
? 'border-cyan-400/40 bg-cyan-500/15 text-cyan-200'
|
||
: 'border-red-200 bg-red-50 text-red-600'
|
||
: isDark
|
||
? 'border-slate-600/40 bg-slate-700/40 text-slate-300 hover:bg-slate-700'
|
||
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
<GitBranch className="h-4 w-4" />
|
||
<span>{sandboxOpen ? '关闭流程图' : '流程图沙箱'}</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-1 overflow-hidden">
|
||
<SixStepChatHistory
|
||
isOpen={historyOpen}
|
||
isDark={isDark}
|
||
conversations={conversationMetas}
|
||
activeId={activeConvId}
|
||
onClose={() => setHistoryOpen(false)}
|
||
onOpen={() => setHistoryOpen(true)}
|
||
onSelect={handleSelectConversation}
|
||
onNew={() => void handleNewConversation()}
|
||
onDelete={(id) => void handleDeleteConversation(id)}
|
||
onRename={(id, title) => void handleRenameConversation(id, title)}
|
||
onClearAll={() => void handleClearAllConversations()}
|
||
/>
|
||
|
||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||
<div className="flex-1 overflow-hidden">
|
||
<SixStepConversation
|
||
messages={messages}
|
||
isGenerating={isGenerating || loading}
|
||
activeMessageId={activeAssistantMsgId}
|
||
expertHandlers={{
|
||
onSelectExpert: handleSelectExpert,
|
||
onRefetchExpert: handleRefetchExpert,
|
||
onCreateExpert: handleCreateExpert,
|
||
onConfirmExpert: handleConfirmExpertSelect,
|
||
}}
|
||
/>
|
||
</div>
|
||
<ChatInputComposer
|
||
value={input}
|
||
onChange={setInput}
|
||
onSend={() => void handleSend()}
|
||
onStop={handleStop}
|
||
placeholder="输入你想要推演的问题或场景..."
|
||
isSending={isGenerating}
|
||
isDarkMode={isDark}
|
||
showBorderTop
|
||
showUpload
|
||
onUploadFile={async () => undefined}
|
||
/>
|
||
</div>
|
||
|
||
{sandboxOpen ? (
|
||
<SixStepFlowSandbox
|
||
message={activeAssistant}
|
||
width={sandboxWidth}
|
||
onResizeStart={handleResizeStart}
|
||
onClose={() => setSandboxOpen(false)}
|
||
onOpenFullscreen={() => setFullscreenOpen(true)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
|
||
{fullscreenOpen ? (
|
||
<SixStepFlowFullscreen message={activeAssistant} onClose={() => setFullscreenOpen(false)} />
|
||
) : null}
|
||
|
||
<McpSelectModal
|
||
open={mcpModal.open}
|
||
expert={mcpModal.expert}
|
||
onCancel={handleMcpModalCancel}
|
||
onConfirm={handleMcpModalConfirm}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|