添加进度面板,优化消息处理逻辑

This commit is contained in:
2026-04-29 16:55:14 +08:00
parent 30d85173ee
commit 2c1afdc97c
6 changed files with 174 additions and 3 deletions
+80 -1
View File
@@ -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>
);
};
+32 -1
View File
@@ -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 = {
+27
View File
@@ -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,
+23
View File
@@ -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
View File
@@ -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"