添加进度面板,优化消息处理逻辑
This commit is contained in:
@@ -7,7 +7,9 @@ import { motion } from "framer-motion";
|
|||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
Chip,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
LinearProgress,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -15,7 +17,9 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import type { Theme } from "@mui/material/styles";
|
import type { Theme } from "@mui/material/styles";
|
||||||
import AutoAwesome from "@mui/icons-material/AutoAwesome";
|
import AutoAwesome from "@mui/icons-material/AutoAwesome";
|
||||||
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
||||||
|
import HourglassEmptyRounded from "@mui/icons-material/HourglassEmptyRounded";
|
||||||
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
|
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
|
||||||
import PauseRounded from "@mui/icons-material/PauseRounded";
|
import PauseRounded from "@mui/icons-material/PauseRounded";
|
||||||
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
||||||
@@ -28,7 +32,7 @@ import {
|
|||||||
import { ChatInlineChart } from "./ChatInlineChart";
|
import { ChatInlineChart } from "./ChatInlineChart";
|
||||||
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
||||||
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
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";
|
import { stripMarkdown } from "./GlobalChatbox.utils";
|
||||||
|
|
||||||
export const TypingIndicator = () => {
|
export const TypingIndicator = () => {
|
||||||
@@ -267,6 +271,9 @@ export const ChatMessageItem = React.memo(
|
|||||||
: "#475569",
|
: "#475569",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{!isUser && !isErrorMessage && message.progress?.length ? (
|
||||||
|
<ChatProgressPanel progress={message.progress} />
|
||||||
|
) : null}
|
||||||
{contentSegments.map((segment, segIdx) => {
|
{contentSegments.map((segment, segIdx) => {
|
||||||
if (segment.type === "text") {
|
if (segment.type === "text") {
|
||||||
const text = segment.content.trim();
|
const text = segment.content.trim();
|
||||||
@@ -424,3 +431,75 @@ export const ChatMessageItem = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
ChatMessageItem.displayName = "ChatMessageItem";
|
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 未返回内容,请稍后重试。",
|
content: "⚠️ **错误:** Agent 未返回内容,请稍后重试。",
|
||||||
isError: true,
|
isError: true,
|
||||||
}
|
}
|
||||||
: m
|
: m.id === assistantId
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
progress: m.progress?.map((item) =>
|
||||||
|
item.status === "running"
|
||||||
|
? { ...item, status: "completed" as const }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: m
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
setIsStreaming(false);
|
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") {
|
} else if (event.type === "error") {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
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 = {
|
export type Message = {
|
||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
progress?: ChatProgress[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Props = {
|
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 () => {
|
it("emits error when response is not ok", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
apiFetch.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import { config } from "@config/config";
|
|||||||
export type StreamEvent =
|
export type StreamEvent =
|
||||||
| { type: "token"; sessionId: string; content: string }
|
| { type: "token"; sessionId: string; content: string }
|
||||||
| { type: "done"; sessionId: string }
|
| { type: "done"; sessionId: string }
|
||||||
|
| {
|
||||||
|
type: "progress";
|
||||||
|
sessionId: string;
|
||||||
|
id: string;
|
||||||
|
phase: string;
|
||||||
|
status: "running" | "completed" | "error";
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "error";
|
type: "error";
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
@@ -121,6 +130,10 @@ export const streamAgentChat = async ({
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
tool?: string;
|
tool?: string;
|
||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>;
|
||||||
|
id?: string;
|
||||||
|
phase?: string;
|
||||||
|
status?: "running" | "completed" | "error";
|
||||||
|
title?: string;
|
||||||
};
|
};
|
||||||
if (event === "token") {
|
if (event === "token") {
|
||||||
onEvent({
|
onEvent({
|
||||||
@@ -128,6 +141,16 @@ export const streamAgentChat = async ({
|
|||||||
sessionId: parsed.session_id ?? "",
|
sessionId: parsed.session_id ?? "",
|
||||||
content: parsed.content ?? "",
|
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") {
|
} else if (event === "done") {
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "done",
|
type: "done",
|
||||||
|
|||||||
+3
-1
@@ -68,7 +68,9 @@
|
|||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".next*/types/**/*.ts",
|
".next*/types/**/*.ts",
|
||||||
".next*/dev/types/**/*.ts"
|
".next*/dev/types/**/*.ts",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules"
|
"node_modules"
|
||||||
|
|||||||
Reference in New Issue
Block a user