添加进度面板,优化消息处理逻辑
This commit is contained in:
@@ -7,7 +7,9 @@ import { motion } from "framer-motion";
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Chip,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
@@ -15,7 +17,9 @@ import {
|
||||
} from "@mui/material";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
import AutoAwesome from "@mui/icons-material/AutoAwesome";
|
||||
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
||||
import HourglassEmptyRounded from "@mui/icons-material/HourglassEmptyRounded";
|
||||
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
|
||||
import PauseRounded from "@mui/icons-material/PauseRounded";
|
||||
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
||||
@@ -28,7 +32,7 @@ import {
|
||||
import { ChatInlineChart } from "./ChatInlineChart";
|
||||
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
||||
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
||||
import type { Message, SpeechState } from "./GlobalChatbox.types";
|
||||
import type { ChatProgress, Message, SpeechState } from "./GlobalChatbox.types";
|
||||
import { stripMarkdown } from "./GlobalChatbox.utils";
|
||||
|
||||
export const TypingIndicator = () => {
|
||||
@@ -267,6 +271,9 @@ export const ChatMessageItem = React.memo(
|
||||
: "#475569",
|
||||
}}
|
||||
>
|
||||
{!isUser && !isErrorMessage && message.progress?.length ? (
|
||||
<ChatProgressPanel progress={message.progress} />
|
||||
) : null}
|
||||
{contentSegments.map((segment, segIdx) => {
|
||||
if (segment.type === "text") {
|
||||
const text = segment.content.trim();
|
||||
@@ -424,3 +431,75 @@ export const ChatMessageItem = React.memo(
|
||||
);
|
||||
|
||||
ChatMessageItem.displayName = "ChatMessageItem";
|
||||
|
||||
const ChatProgressPanel = ({ progress }: { progress: ChatProgress[] }) => {
|
||||
const isComplete = progress.some(
|
||||
(item) => item.phase === "complete" && item.status === "completed",
|
||||
);
|
||||
const latestRunning = isComplete
|
||||
? undefined
|
||||
: [...progress].reverse().find((item) => item.status === "running");
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
p: 1.25,
|
||||
borderRadius: 2.5,
|
||||
bgcolor: "rgba(99, 102, 241, 0.06)",
|
||||
border: "1px solid rgba(99, 102, 241, 0.14)",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<AutoAwesome sx={{ fontSize: 16, color: "primary.main" }} />
|
||||
<Typography variant="caption" fontWeight={800} color="text.primary">
|
||||
Agent 过程
|
||||
</Typography>
|
||||
{latestRunning ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={latestRunning.title}
|
||||
sx={{ height: 20, fontSize: "0.68rem", bgcolor: "rgba(124, 58, 237, 0.08)" }}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
{latestRunning ? <LinearProgress sx={{ height: 4, borderRadius: 99 }} /> : null}
|
||||
<Stack spacing={0.7}>
|
||||
{progress.slice(-5).map((item) => (
|
||||
<Stack key={item.id} direction="row" spacing={0.8} alignItems="flex-start">
|
||||
{item.status === "completed" ? (
|
||||
<CheckCircleRounded sx={{ fontSize: 15, color: "success.main", mt: 0.2 }} />
|
||||
) : item.status === "error" ? (
|
||||
<ErrorOutlineRounded sx={{ fontSize: 15, color: "error.main", mt: 0.2 }} />
|
||||
) : (
|
||||
<HourglassEmptyRounded sx={{ fontSize: 15, color: "primary.main", mt: 0.2 }} />
|
||||
)}
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="caption" color="text.primary" fontWeight={700}>
|
||||
{item.title}
|
||||
</Typography>
|
||||
{item.detail ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="pre"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: "block",
|
||||
mt: 0.25,
|
||||
m: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "0.7rem",
|
||||
}}
|
||||
>
|
||||
{item.detail}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -316,10 +316,41 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
content: "⚠️ **错误:** Agent 未返回内容,请稍后重试。",
|
||||
isError: true,
|
||||
}
|
||||
: m
|
||||
: m.id === assistantId
|
||||
? {
|
||||
...m,
|
||||
progress: m.progress?.map((item) =>
|
||||
item.status === "running"
|
||||
? { ...item, status: "completed" as const }
|
||||
: item,
|
||||
),
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
setIsStreaming(false);
|
||||
} else if (event.type === "progress") {
|
||||
if (!sessionId && event.sessionId) setSessionId(event.sessionId);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => {
|
||||
if (m.id !== assistantId) return m;
|
||||
const progress = [...(m.progress ?? [])];
|
||||
const index = progress.findIndex((item) => item.id === event.id);
|
||||
const nextProgress = {
|
||||
id: event.id,
|
||||
phase: event.phase,
|
||||
status: event.status,
|
||||
title: event.title,
|
||||
detail: event.detail,
|
||||
};
|
||||
if (index >= 0) {
|
||||
progress[index] = nextProgress;
|
||||
} else {
|
||||
progress.push(nextProgress);
|
||||
}
|
||||
return { ...m, progress };
|
||||
})
|
||||
);
|
||||
} else if (event.type === "error") {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
export type ChatProgress = {
|
||||
id: string;
|
||||
phase: string;
|
||||
status: "running" | "completed" | "error";
|
||||
title: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
isError?: boolean;
|
||||
progress?: ChatProgress[];
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
|
||||
@@ -70,6 +70,33 @@ describe("streamAgentChat", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses progress events", async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
body: makeStream([
|
||||
'event: progress\ndata: {"session_id":"s1","id":"p1","phase":"tool","status":"running","title":"正在调用后端数据查询","detail":"GET /api/v1/demo"}\n\n',
|
||||
'event: done\ndata: {"session_id":"s1"}\n\n',
|
||||
]),
|
||||
});
|
||||
|
||||
const events: Array<{ type: string; title?: string; status?: string; detail?: string }> = [];
|
||||
|
||||
await streamAgentChat({
|
||||
message: "hi",
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
|
||||
expect(events[0]).toEqual({
|
||||
type: "progress",
|
||||
sessionId: "s1",
|
||||
id: "p1",
|
||||
phase: "tool",
|
||||
status: "running",
|
||||
title: "正在调用后端数据查询",
|
||||
detail: "GET /api/v1/demo",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits error when response is not ok", async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
|
||||
@@ -4,6 +4,15 @@ import { config } from "@config/config";
|
||||
export type StreamEvent =
|
||||
| { type: "token"; sessionId: string; content: string }
|
||||
| { type: "done"; sessionId: string }
|
||||
| {
|
||||
type: "progress";
|
||||
sessionId: string;
|
||||
id: string;
|
||||
phase: string;
|
||||
status: "running" | "completed" | "error";
|
||||
title: string;
|
||||
detail?: string;
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
sessionId?: string;
|
||||
@@ -121,6 +130,10 @@ export const streamAgentChat = async ({
|
||||
detail?: string;
|
||||
tool?: string;
|
||||
params?: Record<string, unknown>;
|
||||
id?: string;
|
||||
phase?: string;
|
||||
status?: "running" | "completed" | "error";
|
||||
title?: string;
|
||||
};
|
||||
if (event === "token") {
|
||||
onEvent({
|
||||
@@ -128,6 +141,16 @@ export const streamAgentChat = async ({
|
||||
sessionId: parsed.session_id ?? "",
|
||||
content: parsed.content ?? "",
|
||||
});
|
||||
} else if (event === "progress") {
|
||||
onEvent({
|
||||
type: "progress",
|
||||
sessionId: parsed.session_id ?? "",
|
||||
id: parsed.id ?? `${parsed.phase ?? "progress"}-${Date.now()}`,
|
||||
phase: parsed.phase ?? "progress",
|
||||
status: parsed.status ?? "running",
|
||||
title: parsed.title ?? "正在处理",
|
||||
detail: parsed.detail,
|
||||
});
|
||||
} else if (event === "done") {
|
||||
onEvent({
|
||||
type: "done",
|
||||
|
||||
+3
-1
@@ -68,7 +68,9 @@
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next*/types/**/*.ts",
|
||||
".next*/dev/types/**/*.ts"
|
||||
".next*/dev/types/**/*.ts",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
Reference in New Issue
Block a user