添加工具调用解析和聊天工具操作处理

This commit is contained in:
2026-04-03 11:49:05 +08:00
parent a1c8041b11
commit d610a09c14
11 changed files with 1269 additions and 18 deletions
+210 -7
View File
@@ -41,7 +41,18 @@ import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
// Logic
import { streamCopilotChat } from "@/lib/chatStream";
import { parseAssistantMessageSections } from "./chatMessageSections";
import type { StreamEvent } from "@/lib/chatStream";
import {
parseAssistantMessageSections,
parseContentWithToolCalls,
type ContentSegment,
} from "./chatMessageSections";
import { ChatInlineChart } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
import {
useChatToolStore,
type ChatToolAction,
} from "@/store/chatToolStore";
// WebKit Speech Recognition compatibility
interface SpeechRecognitionEvent extends Event {
@@ -213,10 +224,11 @@ type ChatMessageItemProps = {
onResume: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>;
};
const ChatMessageItem = React.memo(
({ message, theme, messageSpeechState, onSpeak, onPause, onResume, onStopSpeech, isTtsSupported }: ChatMessageItemProps) => {
({ message, theme, messageSpeechState, onSpeak, onPause, onResume, onStopSpeech, isTtsSupported, sseChartParams }: ChatMessageItemProps) => {
const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError);
const parsedAssistantSections =
@@ -225,6 +237,12 @@ const ChatMessageItem = React.memo(
: null;
const answerContent = parsedAssistantSections?.answer ?? message.content;
// Parse tool_call blocks from the answer for inline rendering
const contentSegments: ContentSegment[] =
!isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }];
return (
<motion.div
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
@@ -338,9 +356,89 @@ const ChatMessageItem = React.memo(
: "#475569",
}}
>
<div className={markdownStyles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{answerContent || "..."}</ReactMarkdown>
</div>
{contentSegments.map((segment, segIdx) => {
if (segment.type === "text") {
const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null;
return (
<div key={segIdx} className={markdownStyles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{text || "..."}
</ReactMarkdown>
</div>
);
}
if (segment.type === "tool_call") {
if (segment.toolCall.tool === "chart") {
return (
<ChatInlineChart
key={segment.toolCall.id}
{...(segment.toolCall.params as Record<string, unknown>)}
/>
);
}
if (segment.toolCall.tool === "show_chart") {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={(p.chart_type as "line" | "bar" | "pie") ?? "line"}
x_data={(p.x_data as string[]) ?? []}
series={(p.series as import("./ChatInlineChart").ChatChartSeries[]) ?? []}
x_axis_name={(p.x_axis_name as string) ?? undefined}
y_axis_name={(p.y_axis_name as string) ?? undefined}
/>
);
}
return (
<ChatToolCallBlock
key={segment.toolCall.id}
toolCall={segment.toolCall}
/>
);
}
if (segment.type === "tool_call_pending") {
return (
<motion.div
key="tool-pending"
initial={{ opacity: 0 }}
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeInOut",
}}
style={{
marginTop: 8,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<AutoAwesome
sx={{ fontSize: 14, color: "primary.main" }}
/>
<Typography variant="caption" color="text.secondary">
...
</Typography>
</motion.div>
);
}
return null;
})}
{/* SSE-sourced inline charts (from show_chart tool_call events) */}
{sseChartParams?.map((chart, idx) => (
<ChatInlineChart
key={`sse-chart-${idx}`}
title={(chart.params.title as string) ?? undefined}
chart_type={(chart.params.chart_type as "line" | "bar" | "pie") ?? "line"}
x_data={(chart.params.x_data as string[]) ?? []}
series={(chart.params.series as import("./ChatInlineChart").ChatChartSeries[]) ?? []}
x_axis_name={(chart.params.x_axis_name as string) ?? undefined}
y_axis_name={(chart.params.y_axis_name as string) ?? undefined}
/>
))}
</Paper>
{!isUser && !isErrorMessage && isTtsSupported && (
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}>
@@ -546,6 +644,13 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
);
const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null);
const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false);
// SSE tool_call → inline chart data (keyed by assistantMessageId)
const [sseCharts, setSseCharts] = useState<
Record<string, Array<{ tool: string; params: Record<string, unknown> }>>
>({});
const dispatchToolAction = useChatToolStore((s) => s.dispatch);
const abortRef = useRef<AbortController | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -619,6 +724,101 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const controller = new AbortController();
abortRef.current = controller;
// Track SSE tool_call hashes to deduplicate against text-parsed tool_calls
const sseToolHashes = new Set<string>();
const handleSseToolCall = (event: StreamEvent & { type: "tool_call" }) => {
const { tool, params } = event;
const hash = `${tool}:${JSON.stringify(params)}`;
sseToolHashes.add(hash);
const startTime =
(params.start_time as string | undefined) ??
(params.startTime as string | undefined) ??
(params.from as string | undefined) ??
(params.start as string | undefined);
const endTime =
(params.end_time as string | undefined) ??
(params.endTime as string | undefined) ??
(params.to as string | undefined) ??
(params.end as string | undefined);
const resolveScadaFeatureInfos = (): [string, string][] => {
const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) {
const normalizedFeatureInfos = rawFeatureInfos
.map((item) => (Array.isArray(item) ? item : null))
.filter((item): item is [unknown, unknown] => Boolean(item))
.map(
(item) =>
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
string,
string,
],
)
.filter(([id]) => id.trim().length > 0);
if (normalizedFeatureInfos.length > 0) {
return normalizedFeatureInfos;
}
}
const rawDeviceIds =
params.device_ids ??
params.deviceId ??
params.device_id ??
params.id ??
params.ids;
const deviceIds = Array.isArray(rawDeviceIds)
? rawDeviceIds.map((id) => String(id))
: typeof rawDeviceIds === "string"
? rawDeviceIds
.split(",")
.map((id) => id.trim())
.filter(Boolean)
: [];
return deviceIds.map((id) => [id, "scada"]);
};
// show_chart → store as inline chart for rendering
if (tool === "show_chart") {
setSseCharts((prev) => ({
...prev,
[assistantId]: [
...(prev[assistantId] ?? []),
{ tool, params },
],
}));
return;
}
// Other frontend tools → dispatch to chatToolStore immediately
const actionMap: Record<string, () => ChatToolAction | null> = {
locate_nodes: () => ({
type: "locate_nodes" as const,
ids: (params.ids as string[]) ?? [],
}),
locate_pipes: () => ({
type: "locate_pipes" as const,
ids: (params.ids as string[]) ?? [],
}),
view_history: () => ({
type: "view_history" as const,
featureInfos: (params.feature_infos as [string, string][]) ?? [],
dataType: (params.data_type as "realtime" | "scheme" | "none") ?? "realtime",
startTime,
endTime,
}),
view_scada: () => ({
type: "view_scada" as const,
featureInfos: resolveScadaFeatureInfos(),
startTime,
endTime,
}),
};
const buildAction = actionMap[tool];
if (buildAction) {
const action = buildAction();
if (action) dispatchToolAction(action);
}
};
try {
await streamCopilotChat({
message: prompt,
@@ -651,6 +851,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
)
);
setIsStreaming(false);
} else if (event.type === "tool_call") {
handleSseToolCall(event);
}
},
});
@@ -674,7 +876,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
setIsStreaming(false);
}
},
[conversationId, isStreaming, stopListening],
[conversationId, isStreaming, stopListening, dispatchToolAction],
);
const handleSend = async () => {
@@ -764,9 +966,10 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
onResume={handleResumeSpeech}
onStopSpeech={handleStopSpeech}
isTtsSupported={isTtsSupported}
sseChartParams={sseCharts[message.id]}
/>
)),
[messages, theme, speechState, speakingMessageId, handleSpeak, handlePauseSpeech, handleResumeSpeech, handleStopSpeech, isTtsSupported],
[messages, theme, speechState, speakingMessageId, handleSpeak, handlePauseSpeech, handleResumeSpeech, handleStopSpeech, isTtsSupported, sseCharts],
);