feat(chat): 优化分析过程与结果展示
This commit is contained in:
@@ -0,0 +1,384 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
LinearProgress,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
||||||
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
|
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
||||||
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
|
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
|
||||||
|
import StopCircleRounded from "@mui/icons-material/StopCircleRounded";
|
||||||
|
|
||||||
|
import type { AgentActivity, AgentActivityAction } from "@/lib/chatStream";
|
||||||
|
|
||||||
|
const activityAccent = "#0097a7";
|
||||||
|
|
||||||
|
type TimedActivityItem = {
|
||||||
|
status: "running" | "completed" | "error" | "cancelled";
|
||||||
|
startedAt: number;
|
||||||
|
endedAt?: number;
|
||||||
|
elapsedMs?: number;
|
||||||
|
elapsedSnapshotAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (durationMs: number | undefined) => {
|
||||||
|
if (durationMs === undefined || !Number.isFinite(durationMs)) return undefined;
|
||||||
|
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
|
||||||
|
const seconds = Math.round(durationMs / 1000);
|
||||||
|
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getElapsedMs = (
|
||||||
|
item: TimedActivityItem,
|
||||||
|
now: number,
|
||||||
|
) => {
|
||||||
|
if (item.durationMs !== undefined) return item.durationMs;
|
||||||
|
if (item.status === "running") {
|
||||||
|
if (item.elapsedMs !== undefined && item.elapsedSnapshotAt !== undefined) {
|
||||||
|
return Math.max(0, item.elapsedMs + now - item.elapsedSnapshotAt);
|
||||||
|
}
|
||||||
|
return Math.max(0, now - item.startedAt);
|
||||||
|
}
|
||||||
|
return item.endedAt ? Math.max(0, item.endedAt - item.startedAt) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusIcon = ({
|
||||||
|
status,
|
||||||
|
size = 18,
|
||||||
|
}: {
|
||||||
|
status: AgentActivity["status"];
|
||||||
|
size?: number;
|
||||||
|
}) => {
|
||||||
|
if (status === "completed") {
|
||||||
|
return <CheckCircleRounded color="success" sx={{ fontSize: size }} />;
|
||||||
|
}
|
||||||
|
if (status === "error") {
|
||||||
|
return <ErrorOutlineRounded color="error" sx={{ fontSize: size }} />;
|
||||||
|
}
|
||||||
|
if (status === "cancelled") {
|
||||||
|
return <StopCircleRounded color="disabled" sx={{ fontSize: size }} />;
|
||||||
|
}
|
||||||
|
return <AutoAwesomeRounded sx={{ fontSize: size, color: activityAccent }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActionStatusIcon = ({ status }: { status: AgentActivityAction["status"] }) => {
|
||||||
|
if (status === "error") {
|
||||||
|
return <ErrorOutlineRounded sx={{ mt: "2px", fontSize: 14, color: "error.main" }} />;
|
||||||
|
}
|
||||||
|
if (status === "completed") {
|
||||||
|
return <CheckCircleRounded sx={{ mt: "2px", fontSize: 14, color: "success.main" }} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<RadioButtonUncheckedRounded
|
||||||
|
sx={{ mt: "2px", fontSize: 14, color: activityAccent }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }) => {
|
||||||
|
const elapsed = getElapsedMs(action, now);
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="flex-start"
|
||||||
|
sx={{ minWidth: 0, py: 0.55 }}
|
||||||
|
>
|
||||||
|
<ActionStatusIcon status={action.status} />
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", sm: "row" }}
|
||||||
|
spacing={{ xs: 0.2, sm: 1 }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Typography variant="body2" fontWeight={650} sx={{ lineHeight: 1.45 }}>
|
||||||
|
{action.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
|
||||||
|
{formatDuration(elapsed)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
{action.target ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
mt: 0.2,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action.target}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
{action.error ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="error.main"
|
||||||
|
sx={{ display: "block", mt: 0.2, wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{action.error}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentActivityTimeline = ({
|
||||||
|
activities,
|
||||||
|
}: {
|
||||||
|
activities: AgentActivity[];
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
|
||||||
|
const hasRunning = activities.some((activity) => activity.status === "running");
|
||||||
|
const hasError = activities.some((activity) => activity.status === "error");
|
||||||
|
const hasCancelled = activities.some((activity) => activity.status === "cancelled");
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasRunning) return;
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 500);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [hasRunning]);
|
||||||
|
|
||||||
|
const current = [...activities]
|
||||||
|
.reverse()
|
||||||
|
.find((activity) => activity.status === "running") ?? activities.at(-1);
|
||||||
|
const totalDuration = useMemo(() => {
|
||||||
|
if (!activities.length) return undefined;
|
||||||
|
const start = Math.min(...activities.map((activity) => activity.startedAt));
|
||||||
|
const end = hasRunning
|
||||||
|
? now
|
||||||
|
: Math.max(
|
||||||
|
...activities.map((activity) => activity.endedAt ?? activity.startedAt),
|
||||||
|
);
|
||||||
|
return formatDuration(Math.max(0, end - start));
|
||||||
|
}, [activities, hasRunning, now]);
|
||||||
|
const overallStatus: AgentActivity["status"] = hasRunning
|
||||||
|
? "running"
|
||||||
|
: hasError
|
||||||
|
? "error"
|
||||||
|
: hasCancelled
|
||||||
|
? "cancelled"
|
||||||
|
: "completed";
|
||||||
|
const statusLabel = {
|
||||||
|
running: "进行中",
|
||||||
|
completed: "已完成",
|
||||||
|
error: "失败",
|
||||||
|
cancelled: "已停止",
|
||||||
|
}[overallStatus];
|
||||||
|
const statusColor = {
|
||||||
|
running: activityAccent,
|
||||||
|
completed: theme.palette.success.main,
|
||||||
|
error: theme.palette.error.main,
|
||||||
|
cancelled: theme.palette.text.secondary,
|
||||||
|
}[overallStatus];
|
||||||
|
const summary = hasRunning
|
||||||
|
? (current?.title ?? "正在分析")
|
||||||
|
: hasError
|
||||||
|
? "分析未完成"
|
||||||
|
: hasCancelled
|
||||||
|
? "分析已停止"
|
||||||
|
: `已完成 ${activities.length} 个阶段`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${alpha(activityAccent, 0.16)}`,
|
||||||
|
bgcolor: alpha(activityAccent, 0.035),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{ px: 1.4, py: 1.05 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
borderRadius: 2,
|
||||||
|
color: activityAccent,
|
||||||
|
bgcolor: alpha(activityAccent, 0.1),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AutoAwesomeRounded sx={{ fontSize: 17 }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||||
|
<Typography variant="body2" fontWeight={750}>
|
||||||
|
分析过程
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
component="span"
|
||||||
|
direction="row"
|
||||||
|
spacing={0.45}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{ color: statusColor }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
width: 5,
|
||||||
|
height: 5,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: "currentColor",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={700}
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
noWrap
|
||||||
|
sx={{ display: "block", mt: 0.1 }}
|
||||||
|
>
|
||||||
|
{summary}
|
||||||
|
{totalDuration ? ` · ${totalDuration}` : ""}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={expanded ? "收起分析过程" : "展开分析过程"}
|
||||||
|
onClick={() => setExpanded((current) => !current)}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.035),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.07) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
{hasRunning ? (
|
||||||
|
<LinearProgress
|
||||||
|
sx={{
|
||||||
|
height: 2,
|
||||||
|
bgcolor: alpha(activityAccent, 0.08),
|
||||||
|
"& .MuiLinearProgress-bar": { bgcolor: activityAccent },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Collapse in={expanded} timeout={reduceMotion ? 0 : 180}>
|
||||||
|
<Stack
|
||||||
|
spacing={1.1}
|
||||||
|
sx={{
|
||||||
|
px: 1.4,
|
||||||
|
py: 1.15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activities.map((activity, index) => {
|
||||||
|
const elapsed = formatDuration(getElapsedMs(activity, now));
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
key={activity.id}
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 18,
|
||||||
|
flex: "0 0 18px",
|
||||||
|
position: "relative",
|
||||||
|
pt: "2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index < activities.length - 1 ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 19,
|
||||||
|
bottom: -14,
|
||||||
|
left: 8.5,
|
||||||
|
width: "1px",
|
||||||
|
bgcolor: alpha(activityAccent, 0.18),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<StatusIcon status={activity.status} size={17} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Stack direction="row" spacing={1} justifyContent="space-between">
|
||||||
|
<Typography variant="body2" fontWeight={700} sx={{ lineHeight: 1.45 }}>
|
||||||
|
{activity.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{elapsed}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ display: "block", mt: 0.25, lineHeight: 1.5 }}
|
||||||
|
>
|
||||||
|
{activity.reason}
|
||||||
|
</Typography>
|
||||||
|
{activity.actions.length ? (
|
||||||
|
<Stack
|
||||||
|
spacing={0}
|
||||||
|
sx={{
|
||||||
|
mt: 0.6,
|
||||||
|
pl: 1,
|
||||||
|
borderLeft: `1px solid ${alpha(activityAccent, 0.2)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activity.actions.map((action) => (
|
||||||
|
<ActionRow key={action.id} action={action} now={now} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -118,19 +118,10 @@ const PermissionRequestCard = ({
|
|||||||
sx={{
|
sx={{
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
border: `1px solid ${alpha("#fff", 0.72)}`,
|
border: `1px solid ${alpha(accentColor, 0.18)}`,
|
||||||
bgcolor: alpha("#fff", 0.5),
|
bgcolor: alpha(accentColor, 0.035),
|
||||||
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
|
boxShadow: `0 6px 18px ${alpha("#000", 0.04)}`,
|
||||||
backdropFilter: "blur(20px)",
|
|
||||||
position: "relative",
|
position: "relative",
|
||||||
"&::before": {
|
|
||||||
content: '""',
|
|
||||||
position: "absolute",
|
|
||||||
inset: "10px auto 10px 0",
|
|
||||||
width: 3,
|
|
||||||
borderRadius: "0 999px 999px 0",
|
|
||||||
bgcolor: accentColor,
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
@@ -180,6 +171,22 @@ const PermissionRequestCard = ({
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}>
|
<Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.25,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: alpha(accentColor, 0.055),
|
||||||
|
border: `1px solid ${alpha(accentColor, 0.12)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={800}>
|
||||||
|
执行目的
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 0.25, lineHeight: 1.55 }}>
|
||||||
|
{permission.reason?.trim() || "Agent 未提供执行目的"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
px: 1.25,
|
px: 1.25,
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export const TodoPlanCard = ({
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const total = todoUpdate.todos.length;
|
const total = todoUpdate.todos.length;
|
||||||
const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length;
|
const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length;
|
||||||
const running = todoUpdate.todos.find((todo) => todo.status === "in_progress");
|
const runningCount = todoUpdate.todos.filter(
|
||||||
|
(todo) => todo.status === "in_progress",
|
||||||
|
).length;
|
||||||
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
|
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
|
||||||
const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length;
|
const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length;
|
||||||
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
|
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
@@ -77,7 +79,7 @@ export const TodoPlanCard = ({
|
|||||||
? `${completed} 完成 / ${cancelled} 中止`
|
? `${completed} 完成 / ${cancelled} 中止`
|
||||||
: [
|
: [
|
||||||
completed ? `${completed} 完成` : null,
|
completed ? `${completed} 完成` : null,
|
||||||
running ? "1 进行中" : null,
|
runningCount ? `${runningCount} 进行中` : null,
|
||||||
pending ? `${pending} 待办` : null,
|
pending ? `${pending} 待办` : null,
|
||||||
cancelled ? `${cancelled} 中止` : null,
|
cancelled ? `${cancelled} 中止` : null,
|
||||||
].filter(Boolean).join(" / ") || "等待任务";
|
].filter(Boolean).join(" / ") || "等待任务";
|
||||||
@@ -221,14 +223,14 @@ export const TodoPlanCard = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label={running ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"}
|
label={runningCount ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"}
|
||||||
sx={{
|
sx={{
|
||||||
height: 20,
|
height: 20,
|
||||||
borderRadius: "10px",
|
borderRadius: "10px",
|
||||||
fontSize: "0.66rem",
|
fontSize: "0.66rem",
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: running ? "#0277bd" : isAborted ? "text.secondary" : "#00838f",
|
color: runningCount ? "#0277bd" : isAborted ? "text.secondary" : "#00838f",
|
||||||
bgcolor: alpha(running ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08),
|
bgcolor: alpha(runningCount ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08),
|
||||||
"& .MuiChip-label": { px: 0.75 },
|
"& .MuiChip-label": { px: 0.75 },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -305,4 +307,3 @@ export const TodoPlanCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ jest.mock("next/image", () => ({
|
|||||||
|
|
||||||
jest.mock("framer-motion", () => ({
|
jest.mock("framer-motion", () => ({
|
||||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
useReducedMotion: () => false,
|
||||||
motion: {
|
motion: {
|
||||||
div: ({
|
div: ({
|
||||||
children,
|
children,
|
||||||
@@ -44,6 +45,47 @@ jest.mock("./AgentMarkdownBlock", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("AgentTurn speech selection", () => {
|
describe("AgentTurn speech selection", () => {
|
||||||
|
it("mounts the answer only after the complete response is available", () => {
|
||||||
|
const sharedProps = {
|
||||||
|
messageSpeechState: "idle" as const,
|
||||||
|
onSpeak: jest.fn(),
|
||||||
|
onPause: jest.fn(),
|
||||||
|
onResume: jest.fn(),
|
||||||
|
onStopSpeech: jest.fn(),
|
||||||
|
isTtsSupported: true,
|
||||||
|
onCreateBranch: jest.fn(),
|
||||||
|
onReplyPermission: jest.fn(),
|
||||||
|
onReplyQuestion: jest.fn(),
|
||||||
|
onRejectQuestion: jest.fn(),
|
||||||
|
};
|
||||||
|
const { rerender } = render(
|
||||||
|
<AgentTurn
|
||||||
|
{...sharedProps}
|
||||||
|
message={{ id: "assistant-buffered", role: "assistant", content: "" }}
|
||||||
|
isStreaming
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("agent-answer-content")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("正在生成")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AgentTurn
|
||||||
|
{...sharedProps}
|
||||||
|
message={{
|
||||||
|
id: "assistant-buffered",
|
||||||
|
role: "assistant",
|
||||||
|
content: "完整分析结果已生成。",
|
||||||
|
}}
|
||||||
|
isStreaming
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("agent-answer-content")).toHaveTextContent(
|
||||||
|
"完整分析结果已生成。",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows a floating action and reads from the selected text", async () => {
|
it("shows a floating action and reads from the selected text", async () => {
|
||||||
const content = "第一段内容。\n\n第二段内容。";
|
const content = "第一段内容。\n\n第二段内容。";
|
||||||
const speechText = "第一段内容。\n第二段内容。";
|
const speechText = "第一段内容。\n第二段内容。";
|
||||||
@@ -136,6 +178,7 @@ describe("AgentTurn speech selection", () => {
|
|||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["npm test"],
|
patterns: ["npm test"],
|
||||||
target: "npm test",
|
target: "npm test",
|
||||||
|
reason: "需要运行测试确认本次改动没有引入回归。",
|
||||||
always: ["npm test"],
|
always: ["npm test"],
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
@@ -158,10 +201,151 @@ describe("AgentTurn speech selection", () => {
|
|||||||
|
|
||||||
expect(screen.getByRole("button", { name: "允许一次" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "允许一次" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("保存授权范围")).toBeInTheDocument();
|
expect(screen.getByText("保存授权范围")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("执行目的")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("需要运行测试确认本次改动没有引入回归。")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("npm test")).toHaveLength(2);
|
expect(screen.getAllByText("npm test")).toHaveLength(2);
|
||||||
expect(screen.getByTestId("GppGoodRoundedIcon")).toBeInTheDocument();
|
expect(screen.getByTestId("GppGoodRoundedIcon")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "保存授权" }));
|
fireEvent.click(screen.getByRole("button", { name: "保存授权" }));
|
||||||
expect(onReplyPermission).toHaveBeenCalledWith("permission-1", "always");
|
expect(onReplyPermission).toHaveBeenCalledWith("permission-1", "always");
|
||||||
expect(screen.getByRole("button", { name: "拒绝" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "拒绝" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("groups concrete actions under a business activity", () => {
|
||||||
|
render(
|
||||||
|
<AgentTurn
|
||||||
|
message={{
|
||||||
|
id: "assistant-activity",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
activities: [
|
||||||
|
{
|
||||||
|
id: "activity-1",
|
||||||
|
title: "准备供水分区数据",
|
||||||
|
reason: "需要确认拓扑与水库属性完整,才能计算服务范围。",
|
||||||
|
status: "running",
|
||||||
|
startedAt: Date.now(),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "action-1",
|
||||||
|
tool: "tjwater_cli",
|
||||||
|
title: "查询后端数据",
|
||||||
|
status: "completed",
|
||||||
|
target: "network get-all-reservoirs-properties",
|
||||||
|
startedAt: Date.now() - 100,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
durationMs: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
isStreaming
|
||||||
|
messageSpeechState="idle"
|
||||||
|
onSpeak={jest.fn()}
|
||||||
|
onPause={jest.fn()}
|
||||||
|
onResume={jest.fn()}
|
||||||
|
onStopSpeech={jest.fn()}
|
||||||
|
isTtsSupported
|
||||||
|
onCreateBranch={jest.fn()}
|
||||||
|
onReplyPermission={jest.fn()}
|
||||||
|
onReplyQuestion={jest.fn()}
|
||||||
|
onRejectQuestion={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("分析过程")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("进行中")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("准备供水分区数据").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByTestId("KeyboardArrowDownRoundedIcon")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText("需要确认拓扑与水库属性完整,才能计算服务范围。"),
|
||||||
|
).not.toBeVisible();
|
||||||
|
expect(screen.queryByText("查询后端数据")).not.toBeVisible();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
|
||||||
|
expect(screen.getByTestId("KeyboardArrowUpRoundedIcon")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("需要确认拓扑与水库属性完整,才能计算服务范围。")).toBeVisible();
|
||||||
|
expect(screen.getByText("查询后端数据")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the actual number of in-progress session tasks", () => {
|
||||||
|
render(
|
||||||
|
<AgentTurn
|
||||||
|
message={{
|
||||||
|
id: "assistant-todos",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
todos: {
|
||||||
|
sessionId: "session-1",
|
||||||
|
createdAt: 1,
|
||||||
|
todos: [
|
||||||
|
{ id: "todo-1", content: "准备数据", status: "completed" },
|
||||||
|
{ id: "todo-2", content: "分析结果", status: "completed" },
|
||||||
|
{ id: "todo-3", content: "生成建议", status: "in_progress" },
|
||||||
|
{ id: "todo-4", content: "生成图表", status: "in_progress" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
isStreaming
|
||||||
|
messageSpeechState="idle"
|
||||||
|
onSpeak={jest.fn()}
|
||||||
|
onPause={jest.fn()}
|
||||||
|
onResume={jest.fn()}
|
||||||
|
onStopSpeech={jest.fn()}
|
||||||
|
isTtsSupported
|
||||||
|
onCreateBranch={jest.fn()}
|
||||||
|
onReplyPermission={jest.fn()}
|
||||||
|
onReplyQuestion={jest.fn()}
|
||||||
|
onRejectQuestion={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/2 完成 \/ 2 进行中/u)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a failed activity compact until the user expands it", () => {
|
||||||
|
render(
|
||||||
|
<AgentTurn
|
||||||
|
message={{
|
||||||
|
id: "assistant-activity-error",
|
||||||
|
role: "assistant",
|
||||||
|
content: "⚠️ **错误:** 模型请求失败",
|
||||||
|
activities: [
|
||||||
|
{
|
||||||
|
id: "activity-error",
|
||||||
|
title: "正在准备分析",
|
||||||
|
reason: "正在理解请求并确定本次分析需要完成的业务步骤。",
|
||||||
|
status: "error",
|
||||||
|
startedAt: Date.now() - 4100,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
durationMs: 4100,
|
||||||
|
actions: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
isStreaming={false}
|
||||||
|
messageSpeechState="idle"
|
||||||
|
onSpeak={jest.fn()}
|
||||||
|
onPause={jest.fn()}
|
||||||
|
onResume={jest.fn()}
|
||||||
|
onStopSpeech={jest.fn()}
|
||||||
|
isTtsSupported
|
||||||
|
onCreateBranch={jest.fn()}
|
||||||
|
onReplyPermission={jest.fn()}
|
||||||
|
onReplyQuestion={jest.fn()}
|
||||||
|
onRejectQuestion={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("失败")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("分析未完成 · 4.1s")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText("正在理解请求并确定本次分析需要完成的业务步骤。"),
|
||||||
|
).not.toBeVisible();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
|
||||||
|
expect(
|
||||||
|
screen.getByText("正在理解请求并确定本次分析需要完成的业务步骤。"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -33,6 +33,7 @@ import type {
|
|||||||
import { stripMarkdown } from "./globalChatboxUtils";
|
import { stripMarkdown } from "./globalChatboxUtils";
|
||||||
import { findSpeechSelectionStartOffset } from "./speechStartOptions";
|
import { findSpeechSelectionStartOffset } from "./speechStartOptions";
|
||||||
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
||||||
|
import { AgentActivityTimeline } from "./AgentActivityTimeline";
|
||||||
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
|
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
|
||||||
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
||||||
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
|
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
|
||||||
@@ -149,61 +150,6 @@ const StreamingStatus = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StreamingMarkdownBlock = ({
|
|
||||||
text,
|
|
||||||
isStreaming,
|
|
||||||
segmentKey,
|
|
||||||
}: {
|
|
||||||
text: string;
|
|
||||||
isStreaming: boolean;
|
|
||||||
segmentKey: string;
|
|
||||||
}) => {
|
|
||||||
const [streamTextState, setStreamTextState] = React.useState<{
|
|
||||||
displayText: string;
|
|
||||||
animatedTailLength: number;
|
|
||||||
}>({
|
|
||||||
displayText: text,
|
|
||||||
animatedTailLength: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
setStreamTextState((current) => {
|
|
||||||
if (current.displayText === text) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isStreaming) {
|
|
||||||
return {
|
|
||||||
displayText: text,
|
|
||||||
animatedTailLength: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.displayText === text) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
displayText: text,
|
|
||||||
animatedTailLength:
|
|
||||||
text.length > current.displayText.length &&
|
|
||||||
text.startsWith(current.displayText)
|
|
||||||
? Math.min(48, text.length - current.displayText.length)
|
|
||||||
: 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [isStreaming, text]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MarkdownBlock
|
|
||||||
streamFadeKey={`${segmentKey}-${streamTextState.displayText.length}`}
|
|
||||||
streamFadeLength={streamTextState.animatedTailLength}
|
|
||||||
>
|
|
||||||
{streamTextState.displayText}
|
|
||||||
</MarkdownBlock>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AgentTurn = React.memo(
|
export const AgentTurn = React.memo(
|
||||||
({
|
({
|
||||||
message,
|
message,
|
||||||
@@ -220,9 +166,11 @@ export const AgentTurn = React.memo(
|
|||||||
onRejectQuestion,
|
onRejectQuestion,
|
||||||
}: AgentTurnProps) => {
|
}: AgentTurnProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
const isErrorMessage = Boolean(message.isError);
|
const isErrorMessage = Boolean(message.isError);
|
||||||
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
|
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
|
||||||
|
const hasFinalAnswer = message.content.trim().length > 0;
|
||||||
const [isHovered, setIsHovered] = React.useState(false);
|
const [isHovered, setIsHovered] = React.useState(false);
|
||||||
const answerContentRef = React.useRef<HTMLDivElement | null>(null);
|
const answerContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null);
|
const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null);
|
||||||
@@ -230,7 +178,8 @@ export const AgentTurn = React.memo(
|
|||||||
(item) => item.phase === "complete" && item.status === "completed",
|
(item) => item.phase === "complete" && item.status === "completed",
|
||||||
) ?? false;
|
) ?? false;
|
||||||
const isProgressRunning = !isErrorMessage && !isProgressComplete && (
|
const isProgressRunning = !isErrorMessage && !isProgressComplete && (
|
||||||
message.progress?.some((item) => item.status === "running") ?? false
|
(message.activities?.some((item) => item.status === "running") ?? false) ||
|
||||||
|
(message.progress?.some((item) => item.status === "running") ?? false)
|
||||||
);
|
);
|
||||||
|
|
||||||
const parsedAssistantSections = useMemo(
|
const parsedAssistantSections = useMemo(
|
||||||
@@ -456,7 +405,9 @@ export const AgentTurn = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack spacing={1.5}>
|
<Stack spacing={1.5}>
|
||||||
{message.progress?.length ? (
|
{message.activities?.length ? (
|
||||||
|
<AgentActivityTimeline activities={message.activities} />
|
||||||
|
) : message.progress?.length ? (
|
||||||
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
|
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -493,63 +444,98 @@ export const AgentTurn = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack spacing={1.2}>
|
<Stack spacing={1.2}>
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
<Stack
|
||||||
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
spacing={1}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
fontWeight={800}
|
||||||
|
sx={{ letterSpacing: 0.5 }}
|
||||||
|
>
|
||||||
分析结果
|
分析结果
|
||||||
</Typography>
|
</Typography>
|
||||||
{isStreamingAssistant ? <StreamingStatus /> : null}
|
{isStreamingAssistant ? <StreamingStatus /> : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
{contentSegments.map((segment, segIdx) => {
|
{hasFinalAnswer || !isStreamingAssistant ? (
|
||||||
if (segment.type === "text") {
|
<motion.div
|
||||||
const text = segment.content.trim();
|
data-testid="agent-answer-content"
|
||||||
if (!text && contentSegments.length > 1) return null;
|
initial={
|
||||||
return (
|
reduceMotion
|
||||||
<StreamingMarkdownBlock
|
? false
|
||||||
key={segIdx}
|
: { opacity: 0, y: 6, filter: "blur(2px)" }
|
||||||
text={text || "..."}
|
|
||||||
isStreaming={isStreamingAssistant}
|
|
||||||
segmentKey={`${message.id}-${segIdx}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (segment.type === "tool_call") {
|
|
||||||
if (
|
|
||||||
segment.toolCall.tool === "chart" ||
|
|
||||||
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 ?? p.xData ?? p.labels ?? p.categories}
|
|
||||||
series={p.series}
|
|
||||||
x_axis_name={(p.x_axis_name as string) ?? undefined}
|
|
||||||
y_axis_name={(p.y_axis_name as string) ?? undefined}
|
|
||||||
isStreaming={isStreamingAssistant}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||||
<ChatToolCallBlock
|
transition={{
|
||||||
key={segment.toolCall.id}
|
duration: reduceMotion ? 0 : 0.24,
|
||||||
toolCall={segment.toolCall}
|
ease: [0.16, 1, 0.3, 1],
|
||||||
/>
|
}}
|
||||||
);
|
>
|
||||||
}
|
<Stack spacing={1.2}>
|
||||||
if (segment.type === "tool_call_pending") {
|
{contentSegments.map((segment, segIdx) => {
|
||||||
return (
|
if (segment.type === "text") {
|
||||||
<ChartGenerationSkeleton
|
const text = segment.content.trim();
|
||||||
key="tool-pending"
|
if (!text && contentSegments.length > 1) return null;
|
||||||
status={<StreamingStatus />}
|
return (
|
||||||
/>
|
<MarkdownBlock key={segIdx}>
|
||||||
);
|
{text || "..."}
|
||||||
}
|
</MarkdownBlock>
|
||||||
return null;
|
);
|
||||||
})}
|
}
|
||||||
|
if (segment.type === "tool_call") {
|
||||||
|
if (
|
||||||
|
segment.toolCall.tool === "chart" ||
|
||||||
|
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 ??
|
||||||
|
p.xData ??
|
||||||
|
p.labels ??
|
||||||
|
p.categories
|
||||||
|
}
|
||||||
|
series={p.series}
|
||||||
|
x_axis_name={
|
||||||
|
(p.x_axis_name as string) ?? undefined
|
||||||
|
}
|
||||||
|
y_axis_name={
|
||||||
|
(p.y_axis_name as string) ?? undefined
|
||||||
|
}
|
||||||
|
isStreaming={isStreamingAssistant}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ChatToolCallBlock
|
||||||
|
key={segment.toolCall.id}
|
||||||
|
toolCall={segment.toolCall}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (segment.type === "tool_call_pending") {
|
||||||
|
return (
|
||||||
|
<ChartGenerationSkeleton
|
||||||
|
key="tool-pending"
|
||||||
|
status={<StreamingStatus />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -229,18 +229,23 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
|
const latestAssistant = [...messages]
|
||||||
|
.reverse()
|
||||||
|
.find((message) => message.role === "assistant");
|
||||||
|
if (latestAssistant?.content.trim()) {
|
||||||
|
cancelStreamingScroll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isNearBottomRef.current) return;
|
if (!isNearBottomRef.current) return;
|
||||||
scheduleStreamingScrollToBottom();
|
scheduleStreamingScrollToBottom();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cancelStreamingScroll();
|
cancelStreamingScroll();
|
||||||
scrollToBottom("smooth");
|
|
||||||
}, [
|
}, [
|
||||||
cancelStreamingScroll,
|
cancelStreamingScroll,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
messages,
|
messages,
|
||||||
scheduleStreamingScrollToBottom,
|
scheduleStreamingScrollToBottom,
|
||||||
scrollToBottom,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AgentActivity,
|
||||||
AgentQuestionRequest,
|
AgentQuestionRequest,
|
||||||
AgentTodoUpdate,
|
AgentTodoUpdate,
|
||||||
} from "@/lib/chatStream";
|
} from "@/lib/chatStream";
|
||||||
@@ -42,6 +43,8 @@ export type AgentPermissionRequest = {
|
|||||||
permission: string;
|
permission: string;
|
||||||
patterns: string[];
|
patterns: string[];
|
||||||
target?: string;
|
target?: string;
|
||||||
|
activityId?: string;
|
||||||
|
reason?: string;
|
||||||
always: string[];
|
always: string[];
|
||||||
tool?: {
|
tool?: {
|
||||||
messageID: string;
|
messageID: string;
|
||||||
@@ -59,6 +62,7 @@ export type Message = {
|
|||||||
content: string;
|
content: string;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
progress?: ChatProgress[];
|
progress?: ChatProgress[];
|
||||||
|
activities?: AgentActivity[];
|
||||||
artifacts?: AgentArtifact[];
|
artifacts?: AgentArtifact[];
|
||||||
permissions?: AgentPermissionRequest[];
|
permissions?: AgentPermissionRequest[];
|
||||||
questions?: AgentQuestionRequest[];
|
questions?: AgentQuestionRequest[];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AgentActivity,
|
||||||
AgentQuestionRequest,
|
AgentQuestionRequest,
|
||||||
AgentTodoUpdate,
|
AgentTodoUpdate,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
@@ -79,6 +80,48 @@ export const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const upsertActivity = (
|
||||||
|
activities: AgentActivity[] | undefined,
|
||||||
|
event: StreamEvent & { type: "activity_update" },
|
||||||
|
) => {
|
||||||
|
const next = [...(activities ?? [])];
|
||||||
|
const index = next.findIndex((activity) => activity.id === event.activity.id);
|
||||||
|
if (index >= 0) next[index] = event.activity;
|
||||||
|
else next.push(event.activity);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const completeRunningActivities = (
|
||||||
|
activities: AgentActivity[] | undefined,
|
||||||
|
status: "completed" | "error" | "cancelled" = "completed",
|
||||||
|
) => activities?.map((activity) => {
|
||||||
|
if (activity.status !== "running") return activity;
|
||||||
|
const endedAt = Date.now();
|
||||||
|
return {
|
||||||
|
...activity,
|
||||||
|
status,
|
||||||
|
actions: activity.actions.map((action) =>
|
||||||
|
action.status === "running"
|
||||||
|
? {
|
||||||
|
...action,
|
||||||
|
status: status === "error" ? "error" as const : "completed" as const,
|
||||||
|
endedAt,
|
||||||
|
elapsedMs: undefined,
|
||||||
|
elapsedSnapshotAt: undefined,
|
||||||
|
durationMs: Math.max(0, endedAt - action.startedAt),
|
||||||
|
...(status === "error"
|
||||||
|
? { error: action.error ?? "活动执行失败" }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: action,
|
||||||
|
),
|
||||||
|
endedAt,
|
||||||
|
elapsedMs: undefined,
|
||||||
|
elapsedSnapshotAt: undefined,
|
||||||
|
durationMs: Math.max(0, endedAt - activity.startedAt),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
|
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
|
||||||
todoUpdate
|
todoUpdate
|
||||||
? {
|
? {
|
||||||
@@ -107,6 +150,8 @@ export const upsertPermission = (
|
|||||||
permission: event.permission,
|
permission: event.permission,
|
||||||
patterns: event.patterns,
|
patterns: event.patterns,
|
||||||
target: event.target,
|
target: event.target,
|
||||||
|
activityId: event.activityId,
|
||||||
|
reason: event.reason,
|
||||||
always: event.always,
|
always: event.always,
|
||||||
tool: event.tool,
|
tool: event.tool,
|
||||||
createdAt: event.createdAt,
|
createdAt: event.createdAt,
|
||||||
@@ -405,6 +450,7 @@ export const rejectOpenQuestionsAfterAbort = (
|
|||||||
|
|
||||||
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
||||||
const completedProgress = completeRunningProgress(message.progress);
|
const completedProgress = completeRunningProgress(message.progress);
|
||||||
|
const cancelledActivities = completeRunningActivities(message.activities, "cancelled");
|
||||||
const cancelledTodos = cancelRunningTodos(message.todos);
|
const cancelledTodos = cancelRunningTodos(message.todos);
|
||||||
const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions);
|
const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions);
|
||||||
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
|
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
|
||||||
@@ -414,6 +460,7 @@ export const finalizeAssistantMessageAfterAbort = (message: Message): Message =>
|
|||||||
Boolean(abortedPermissions?.length) ||
|
Boolean(abortedPermissions?.length) ||
|
||||||
Boolean(rejectedQuestions?.length) ||
|
Boolean(rejectedQuestions?.length) ||
|
||||||
Boolean(completedProgress?.length) ||
|
Boolean(completedProgress?.length) ||
|
||||||
|
Boolean(cancelledActivities?.length) ||
|
||||||
Boolean(cancelledTodos);
|
Boolean(cancelledTodos);
|
||||||
|
|
||||||
if (!hasVisibleOutput) {
|
if (!hasVisibleOutput) {
|
||||||
@@ -425,6 +472,7 @@ export const finalizeAssistantMessageAfterAbort = (message: Message): Message =>
|
|||||||
content: message.content || "⚠️ **请求已中断**",
|
content: message.content || "⚠️ **请求已中断**",
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completedProgress,
|
progress: completedProgress,
|
||||||
|
activities: cancelledActivities,
|
||||||
permissions: abortedPermissions,
|
permissions: abortedPermissions,
|
||||||
questions: rejectedQuestions,
|
questions: rejectedQuestions,
|
||||||
todos: cancelledTodos,
|
todos: cancelledTodos,
|
||||||
|
|||||||
@@ -132,6 +132,80 @@ describe("useAgentChatSession actions", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies an activity phase and todo snapshot atomically before revealing the final answer", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "activity_update",
|
||||||
|
sessionId: "session-1",
|
||||||
|
activity: {
|
||||||
|
id: "activity-analyze",
|
||||||
|
title: "分析管网数据",
|
||||||
|
reason: "需要识别影响供水能力的关键管段。",
|
||||||
|
status: "running",
|
||||||
|
actions: [],
|
||||||
|
startedAt: 1000,
|
||||||
|
},
|
||||||
|
todos: [
|
||||||
|
{
|
||||||
|
id: "todo-data",
|
||||||
|
content: "准备管网数据",
|
||||||
|
status: "completed",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "todo-analysis",
|
||||||
|
content: "识别瓶颈管段",
|
||||||
|
status: "in_progress",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
todosCreatedAt: 1001,
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "final_answer",
|
||||||
|
sessionId: "session-1",
|
||||||
|
content: "已识别关键瓶颈管段。",
|
||||||
|
});
|
||||||
|
onEvent({ type: "done", sessionId: "session-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendPrompt("分析管网瓶颈");
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantMessage = result.current.messages.at(-1);
|
||||||
|
expect(assistantMessage).toMatchObject({
|
||||||
|
role: "assistant",
|
||||||
|
content: "已识别关键瓶颈管段。",
|
||||||
|
todos: {
|
||||||
|
sessionId: "session-1",
|
||||||
|
createdAt: 1001,
|
||||||
|
todos: [
|
||||||
|
expect.objectContaining({ id: "todo-data", status: "completed" }),
|
||||||
|
expect.objectContaining({ id: "todo-analysis", status: "completed" }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
assistantMessage?.activities?.find(
|
||||||
|
(activity) => activity.id === "activity-analyze",
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
title: "分析管网数据",
|
||||||
|
status: "completed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("finalizes running progress when aborting an active prompt", async () => {
|
it("finalizes running progress when aborting an active prompt", async () => {
|
||||||
listChatSessions.mockResolvedValue([]);
|
listChatSessions.mockResolvedValue([]);
|
||||||
jest.mocked(streamAgentChat).mockImplementationOnce(
|
jest.mocked(streamAgentChat).mockImplementationOnce(
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ describe("useAgentChatSession lifecycle and resume", () => {
|
|||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "todo-2",
|
id: "todo-2",
|
||||||
status: "in_progress",
|
status: "completed",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
applyQuestionResponse,
|
applyQuestionResponse,
|
||||||
cancelRunningTodos,
|
cancelRunningTodos,
|
||||||
|
completeRunningActivities,
|
||||||
completeRunningProgress,
|
completeRunningProgress,
|
||||||
createAssistantMessage,
|
createAssistantMessage,
|
||||||
createTodoUpdateFromEvent,
|
createTodoUpdateFromEvent,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
normalizeSessionTodos,
|
normalizeSessionTodos,
|
||||||
toPermissionStatus,
|
toPermissionStatus,
|
||||||
upsertPermission,
|
upsertPermission,
|
||||||
|
upsertActivity,
|
||||||
upsertProgress,
|
upsertProgress,
|
||||||
upsertQuestionAcrossMessages,
|
upsertQuestionAcrossMessages,
|
||||||
} from "./agentChatSessionState";
|
} from "./agentChatSessionState";
|
||||||
@@ -48,68 +50,21 @@ import type {
|
|||||||
UseAgentChatSessionOptions,
|
UseAgentChatSessionOptions,
|
||||||
} from "./useAgentChatSession.types";
|
} from "./useAgentChatSession.types";
|
||||||
|
|
||||||
const TOKEN_PLAYBACK_INTERVAL_MS = 16;
|
const completeTodos = (todoUpdate: Message["todos"]) =>
|
||||||
const TOKEN_PLAYBACK_BASE_CHARS = 28;
|
todoUpdate
|
||||||
const TOKEN_PLAYBACK_MAX_CHARS = 160;
|
? {
|
||||||
|
...todoUpdate,
|
||||||
const sliceCodePoints = (value: string, count: number) =>
|
todos: todoUpdate.todos.map((todo) =>
|
||||||
Array.from(value).slice(0, count).join("");
|
todo.status === "pending" || todo.status === "in_progress"
|
||||||
|
? {
|
||||||
let cachedSegmenter: Intl.Segmenter | null | undefined;
|
...todo,
|
||||||
|
status: "completed" as const,
|
||||||
const getSegmenter = () => {
|
updatedAt: Date.now(),
|
||||||
if (cachedSegmenter !== undefined) return cachedSegmenter;
|
}
|
||||||
cachedSegmenter =
|
: todo,
|
||||||
typeof Intl !== "undefined" && "Segmenter" in Intl
|
),
|
||||||
? new Intl.Segmenter("zh", { granularity: "word" })
|
|
||||||
: null;
|
|
||||||
return cachedSegmenter;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPlaybackChunkSize = (bufferLength: number) => {
|
|
||||||
if (bufferLength >= 600) return TOKEN_PLAYBACK_MAX_CHARS;
|
|
||||||
if (bufferLength >= 300) return 112;
|
|
||||||
if (bufferLength >= 140) return 72;
|
|
||||||
if (bufferLength >= 64) return 44;
|
|
||||||
return TOKEN_PLAYBACK_BASE_CHARS;
|
|
||||||
};
|
|
||||||
|
|
||||||
const takeNextTokenPlaybackChunk = (content: string, maxChars: number) => {
|
|
||||||
if (content.length <= maxChars) return content;
|
|
||||||
const targetChars = Math.max(12, Math.floor(maxChars * 0.68));
|
|
||||||
|
|
||||||
const segmenter = getSegmenter();
|
|
||||||
if (segmenter) {
|
|
||||||
let chunk = "";
|
|
||||||
for (const segment of segmenter.segment(content)) {
|
|
||||||
chunk += segment.segment;
|
|
||||||
if (
|
|
||||||
chunk.length >= maxChars ||
|
|
||||||
(chunk.length >= targetChars &&
|
|
||||||
/[\s,。!?、;:,.!?;:]/u.test(segment.segment))
|
|
||||||
) {
|
|
||||||
return chunk;
|
|
||||||
}
|
}
|
||||||
}
|
: undefined;
|
||||||
}
|
|
||||||
|
|
||||||
const phrase = content.match(/^.{1,12}?[\s,。!?、;:,.!?;:]+/u)?.[0];
|
|
||||||
if (phrase) return phrase;
|
|
||||||
|
|
||||||
const cjkChunk = content.match(
|
|
||||||
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/u,
|
|
||||||
)?.[0];
|
|
||||||
if (cjkChunk) return sliceCodePoints(cjkChunk, Math.min(maxChars, 18));
|
|
||||||
|
|
||||||
const wordChunk = content.match(/^\S+\s*/u)?.[0];
|
|
||||||
if (wordChunk) {
|
|
||||||
return wordChunk.length <= maxChars
|
|
||||||
? wordChunk
|
|
||||||
: sliceCodePoints(wordChunk, maxChars);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sliceCodePoints(content, Math.min(maxChars, 12));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useAgentChatSession = ({
|
export const useAgentChatSession = ({
|
||||||
projectId,
|
projectId,
|
||||||
@@ -136,11 +91,6 @@ export const useAgentChatSession = ({
|
|||||||
const isSessionTitleManuallyEditedRef = useRef(false);
|
const isSessionTitleManuallyEditedRef = useRef(false);
|
||||||
const cancelPromiseRef = useRef<Promise<void> | null>(null);
|
const cancelPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
const titleUpdateNonceRef = useRef(0);
|
const titleUpdateNonceRef = useRef(0);
|
||||||
const pendingTokenRef = useRef<{
|
|
||||||
assistantMessageId: string;
|
|
||||||
content: string;
|
|
||||||
} | null>(null);
|
|
||||||
const tokenPlaybackIntervalRef = useRef<number | null>(null);
|
|
||||||
const credentialRefreshRequestIdsRef = useRef(new Set<string>());
|
const credentialRefreshRequestIdsRef = useRef(new Set<string>());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -168,83 +118,6 @@ export const useAgentChatSession = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cancelTokenPlayback = useCallback(() => {
|
|
||||||
const intervalId = tokenPlaybackIntervalRef.current;
|
|
||||||
if (intervalId === null) return;
|
|
||||||
window.clearInterval(intervalId);
|
|
||||||
tokenPlaybackIntervalRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const flushPendingTokens = useCallback(() => {
|
|
||||||
const pending = pendingTokenRef.current;
|
|
||||||
pendingTokenRef.current = null;
|
|
||||||
cancelTokenPlayback();
|
|
||||||
if (!pending) return;
|
|
||||||
applyTokenContent(pending.assistantMessageId, pending.content);
|
|
||||||
}, [applyTokenContent, cancelTokenPlayback]);
|
|
||||||
|
|
||||||
const scheduleTokenPlayback = useCallback(() => {
|
|
||||||
if (tokenPlaybackIntervalRef.current !== null) return;
|
|
||||||
const id = window.setInterval(() => {
|
|
||||||
const pending = pendingTokenRef.current;
|
|
||||||
if (!pending) {
|
|
||||||
window.clearInterval(id);
|
|
||||||
tokenPlaybackIntervalRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunk = takeNextTokenPlaybackChunk(
|
|
||||||
pending.content,
|
|
||||||
getPlaybackChunkSize(pending.content.length),
|
|
||||||
);
|
|
||||||
if (!chunk) {
|
|
||||||
window.clearInterval(id);
|
|
||||||
tokenPlaybackIntervalRef.current = null;
|
|
||||||
pendingTokenRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = pending.content.slice(chunk.length);
|
|
||||||
pendingTokenRef.current = remaining
|
|
||||||
? { assistantMessageId: pending.assistantMessageId, content: remaining }
|
|
||||||
: null;
|
|
||||||
applyTokenContent(pending.assistantMessageId, chunk);
|
|
||||||
|
|
||||||
if (!remaining) {
|
|
||||||
window.clearInterval(id);
|
|
||||||
tokenPlaybackIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
}, TOKEN_PLAYBACK_INTERVAL_MS);
|
|
||||||
tokenPlaybackIntervalRef.current = id;
|
|
||||||
}, [applyTokenContent]);
|
|
||||||
|
|
||||||
const queueTokenContent = useCallback(
|
|
||||||
(assistantMessageId: string, content: string) => {
|
|
||||||
const pending = pendingTokenRef.current;
|
|
||||||
if (pending && pending.assistantMessageId !== assistantMessageId) {
|
|
||||||
flushPendingTokens();
|
|
||||||
}
|
|
||||||
pendingTokenRef.current = {
|
|
||||||
assistantMessageId,
|
|
||||||
content:
|
|
||||||
pending?.assistantMessageId === assistantMessageId
|
|
||||||
? pending.content + content
|
|
||||||
: content,
|
|
||||||
};
|
|
||||||
scheduleTokenPlayback();
|
|
||||||
},
|
|
||||||
[flushPendingTokens, scheduleTokenPlayback],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
pendingTokenRef.current = null;
|
|
||||||
cancelTokenPlayback();
|
|
||||||
},
|
|
||||||
[cancelTokenPlayback],
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
|
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
|
||||||
}, [isSessionTitleManuallyEdited]);
|
}, [isSessionTitleManuallyEdited]);
|
||||||
@@ -391,10 +264,6 @@ export const useAgentChatSession = ({
|
|||||||
assistantMessageId?: string;
|
assistantMessageId?: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
if (event.type !== "token") {
|
|
||||||
flushPendingTokens();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.type !== "session_title" &&
|
event.type !== "session_title" &&
|
||||||
"sessionId" in event &&
|
"sessionId" in event &&
|
||||||
@@ -447,7 +316,36 @@ export const useAgentChatSession = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "token") {
|
if (event.type === "token") {
|
||||||
queueTokenContent(assistantMessageId, event.content);
|
applyTokenContent(assistantMessageId, event.content);
|
||||||
|
} else if (event.type === "final_answer") {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? { ...message, content: event.content, isError: false }
|
||||||
|
: message,
|
||||||
|
);
|
||||||
|
messagesRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else if (event.type === "activity_update") {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? { ...message, activities: upsertActivity(message.activities, event) }
|
||||||
|
: message,
|
||||||
|
);
|
||||||
|
return event.todos
|
||||||
|
? normalizeSessionTodos(
|
||||||
|
next,
|
||||||
|
{
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
todos: event.todos,
|
||||||
|
createdAt: event.todosCreatedAt ?? Date.now(),
|
||||||
|
},
|
||||||
|
assistantMessageId,
|
||||||
|
)
|
||||||
|
: next;
|
||||||
|
});
|
||||||
} else if (event.type === "progress") {
|
} else if (event.type === "progress") {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) =>
|
prev.map((message) =>
|
||||||
@@ -583,6 +481,7 @@ export const useAgentChatSession = ({
|
|||||||
prev.map((message) => {
|
prev.map((message) => {
|
||||||
if (message.id !== assistantMessageId) return message;
|
if (message.id !== assistantMessageId) return message;
|
||||||
const completedProgress = completeRunningProgress(message.progress);
|
const completedProgress = completeRunningProgress(message.progress);
|
||||||
|
const completedActivities = completeRunningActivities(message.activities);
|
||||||
if (
|
if (
|
||||||
message.content.trim().length === 0 &&
|
message.content.trim().length === 0 &&
|
||||||
!(message.artifacts?.length)
|
!(message.artifacts?.length)
|
||||||
@@ -592,9 +491,16 @@ export const useAgentChatSession = ({
|
|||||||
content:
|
content:
|
||||||
"Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
|
"Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
|
||||||
progress: completedProgress,
|
progress: completedProgress,
|
||||||
|
activities: completedActivities,
|
||||||
|
todos: completeTodos(message.todos),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ...message, progress: completedProgress };
|
return {
|
||||||
|
...message,
|
||||||
|
progress: completedProgress,
|
||||||
|
activities: completedActivities,
|
||||||
|
todos: completeTodos(message.todos),
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
@@ -607,6 +513,7 @@ export const useAgentChatSession = ({
|
|||||||
content: message.content || `⚠️ **错误:** ${event.message}`,
|
content: message.content || `⚠️ **错误:** ${event.message}`,
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completeRunningProgress(message.progress),
|
progress: completeRunningProgress(message.progress),
|
||||||
|
activities: completeRunningActivities(message.activities, "error"),
|
||||||
todos: cancelRunningTodos(message.todos),
|
todos: cancelRunningTodos(message.todos),
|
||||||
}
|
}
|
||||||
: message,
|
: message,
|
||||||
@@ -623,6 +530,7 @@ export const useAgentChatSession = ({
|
|||||||
content: message.content || `⚠️ **${event.message}**`,
|
content: message.content || `⚠️ **${event.message}**`,
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completeRunningProgress(message.progress),
|
progress: completeRunningProgress(message.progress),
|
||||||
|
activities: completeRunningActivities(message.activities, "error"),
|
||||||
todos: cancelRunningTodos(message.todos),
|
todos: cancelRunningTodos(message.todos),
|
||||||
}
|
}
|
||||||
: message,
|
: message,
|
||||||
@@ -632,12 +540,11 @@ export const useAgentChatSession = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
applyTokenContent,
|
||||||
appendArtifact,
|
appendArtifact,
|
||||||
flushPendingTokens,
|
|
||||||
getLastAssistantMessageId,
|
getLastAssistantMessageId,
|
||||||
handleCredentialRefresh,
|
handleCredentialRefresh,
|
||||||
onToolCall,
|
onToolCall,
|
||||||
queueTokenContent,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -654,20 +561,18 @@ export const useAgentChatSession = ({
|
|||||||
onEvent: (event) => applyStreamEvent(event),
|
onEvent: (event) => applyStreamEvent(event),
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
flushPendingTokens();
|
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
|
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
flushPendingTokens();
|
|
||||||
if (abortRef.current === controller) {
|
if (abortRef.current === controller) {
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[applyStreamEvent, flushPendingTokens],
|
[applyStreamEvent],
|
||||||
);
|
);
|
||||||
resumeStreamingSessionRef.current = resumeStreamingSession;
|
resumeStreamingSessionRef.current = resumeStreamingSession;
|
||||||
|
|
||||||
@@ -716,7 +621,6 @@ export const useAgentChatSession = ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
flushPendingTokens();
|
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev
|
prev
|
||||||
@@ -733,6 +637,7 @@ export const useAgentChatSession = ({
|
|||||||
message.content.trim().length === 0 &&
|
message.content.trim().length === 0 &&
|
||||||
!(message.artifacts?.length) &&
|
!(message.artifacts?.length) &&
|
||||||
!(message.progress?.length) &&
|
!(message.progress?.length) &&
|
||||||
|
!(message.activities?.length) &&
|
||||||
!message.todos
|
!message.todos
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -747,20 +652,19 @@ export const useAgentChatSession = ({
|
|||||||
content: `⚠️ **错误:** ${String(error)}`,
|
content: `⚠️ **错误:** ${String(error)}`,
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completeRunningProgress(message.progress),
|
progress: completeRunningProgress(message.progress),
|
||||||
|
activities: completeRunningActivities(message.activities, "error"),
|
||||||
}
|
}
|
||||||
: message,
|
: message,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
} finally {
|
} finally {
|
||||||
flushPendingTokens();
|
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
applyStreamEvent,
|
applyStreamEvent,
|
||||||
flushPendingTokens,
|
|
||||||
getApprovalMode,
|
getApprovalMode,
|
||||||
getModel,
|
getModel,
|
||||||
isHydrating,
|
isHydrating,
|
||||||
@@ -773,7 +677,6 @@ export const useAgentChatSession = ({
|
|||||||
const abort = useCallback(() => {
|
const abort = useCallback(() => {
|
||||||
const controller = abortRef.current;
|
const controller = abortRef.current;
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
flushPendingTokens();
|
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
const assistantMessageId = getLastAssistantMessageId();
|
const assistantMessageId = getLastAssistantMessageId();
|
||||||
|
|
||||||
@@ -796,7 +699,7 @@ export const useAgentChatSession = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
cancelPromiseRef.current = trackedCancelPromise;
|
cancelPromiseRef.current = trackedCancelPromise;
|
||||||
}, [flushPendingTokens, getLastAssistantMessageId]);
|
}, [getLastAssistantMessageId]);
|
||||||
|
|
||||||
const replyPermission = useCallback(
|
const replyPermission = useCallback(
|
||||||
async (requestId: string, reply: PermissionDecision) => {
|
async (requestId: string, reply: PermissionDecision) => {
|
||||||
@@ -1009,7 +912,6 @@ export const useAgentChatSession = ({
|
|||||||
const createSession = useCallback(() => {
|
const createSession = useCallback(() => {
|
||||||
if (isHydrating || isStreaming) return;
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
flushPendingTokens();
|
|
||||||
const controller = abortRef.current;
|
const controller = abortRef.current;
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
@@ -1020,7 +922,7 @@ export const useAgentChatSession = ({
|
|||||||
setIsSessionTitleManuallyEdited(false);
|
setIsSessionTitleManuallyEdited(false);
|
||||||
setSessionId(undefined);
|
setSessionId(undefined);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}, [flushPendingTokens, isHydrating, isStreaming]);
|
}, [isHydrating, isStreaming]);
|
||||||
|
|
||||||
const switchSession = useCallback(
|
const switchSession = useCallback(
|
||||||
async (nextSessionId: string, optimisticTitle?: string) => {
|
async (nextSessionId: string, optimisticTitle?: string) => {
|
||||||
|
|||||||
@@ -100,6 +100,27 @@ describe("streamAgentChat", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses one complete final answer event", async () => {
|
||||||
|
mockNewSessionStream({
|
||||||
|
ok: true,
|
||||||
|
body: makeStream([
|
||||||
|
'event: final_answer\ndata: {"session_id":"s1","content":"完整分析结果"}\n\n',
|
||||||
|
'event: done\ndata: {"session_id":"s1"}\n\n',
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const events: StreamEvent[] = [];
|
||||||
|
|
||||||
|
await streamAgentChat({
|
||||||
|
message: "分析",
|
||||||
|
onEvent: (event) => events.push(event),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ type: "final_answer", sessionId: "s1", content: "完整分析结果" },
|
||||||
|
{ type: "done", sessionId: "s1" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("parses state events from a resumed stream", async () => {
|
it("parses state events from a resumed stream", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
apiFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -172,6 +193,41 @@ describe("streamAgentChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses grouped activities and inherited permission reasons", async () => {
|
||||||
|
mockNewSessionStream({
|
||||||
|
ok: true,
|
||||||
|
body: makeStream([
|
||||||
|
'event: activity_update\ndata: {"session_id":"s1","activity":{"id":"a1","title":"准备分析数据","reason":"需要先确认输入数据完整。","status":"running","started_at":100,"elapsed_ms":25,"actions":[{"id":"x1","tool":"tjwater_cli","title":"查询后端数据","status":"running","target":"data list","started_at":110,"elapsed_ms":15}]},"todos":[{"id":"t1","content":"准备分析数据","status":"completed","priority":"high"},{"id":"t2","content":"生成分析结果","status":"in_progress","priority":"medium"}],"todos_created_at":125}\n\n',
|
||||||
|
'event: permission_request\ndata: {"session_id":"s1","request_id":"p1","permission":"bash","patterns":["python3 analysis.py"],"target":"python3 analysis.py","always":[],"activity_id":"a1","reason":"需要先确认输入数据完整。","created_at":123}\n\n',
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const events: StreamEvent[] = [];
|
||||||
|
|
||||||
|
await streamAgentChat({ message: "分析", onEvent: (event) => events.push(event) });
|
||||||
|
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
type: "activity_update",
|
||||||
|
sessionId: "s1",
|
||||||
|
activity: {
|
||||||
|
id: "a1",
|
||||||
|
title: "准备分析数据",
|
||||||
|
reason: "需要先确认输入数据完整。",
|
||||||
|
status: "running",
|
||||||
|
actions: [expect.objectContaining({ id: "x1", target: "data list" })],
|
||||||
|
},
|
||||||
|
todos: [
|
||||||
|
expect.objectContaining({ id: "t1", status: "completed" }),
|
||||||
|
expect.objectContaining({ id: "t2", status: "in_progress" }),
|
||||||
|
],
|
||||||
|
todosCreatedAt: 125,
|
||||||
|
});
|
||||||
|
expect(events[1]).toMatchObject({
|
||||||
|
type: "permission_request",
|
||||||
|
activityId: "a1",
|
||||||
|
reason: "需要先确认输入数据完整。",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("parses credential refresh lifecycle events", async () => {
|
it("parses credential refresh lifecycle events", async () => {
|
||||||
mockNewSessionStream({
|
mockNewSessionStream({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -246,6 +302,8 @@ describe("streamAgentChat", () => {
|
|||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["rm *"],
|
patterns: ["rm *"],
|
||||||
target: "rm tmp.txt",
|
target: "rm tmp.txt",
|
||||||
|
activityId: undefined,
|
||||||
|
reason: undefined,
|
||||||
always: ["rm *"],
|
always: ["rm *"],
|
||||||
tool: undefined,
|
tool: undefined,
|
||||||
createdAt: 123,
|
createdAt: 123,
|
||||||
|
|||||||
@@ -59,6 +59,35 @@ export type AgentTodoUpdate = {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AgentActivityStatus = "running" | "completed" | "error" | "cancelled";
|
||||||
|
|
||||||
|
export type AgentActivityAction = {
|
||||||
|
id: string;
|
||||||
|
tool: string;
|
||||||
|
title: string;
|
||||||
|
status: "running" | "completed" | "error";
|
||||||
|
target?: string;
|
||||||
|
error?: string;
|
||||||
|
startedAt: number;
|
||||||
|
endedAt?: number;
|
||||||
|
elapsedMs?: number;
|
||||||
|
elapsedSnapshotAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentActivity = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
reason: string;
|
||||||
|
status: AgentActivityStatus;
|
||||||
|
actions: AgentActivityAction[];
|
||||||
|
startedAt: number;
|
||||||
|
endedAt?: number;
|
||||||
|
elapsedMs?: number;
|
||||||
|
elapsedSnapshotAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type StreamEvent =
|
export type StreamEvent =
|
||||||
| {
|
| {
|
||||||
type: "state";
|
type: "state";
|
||||||
@@ -68,8 +97,16 @@ export type StreamEvent =
|
|||||||
runStatus?: string;
|
runStatus?: string;
|
||||||
}
|
}
|
||||||
| { type: "token"; sessionId: string; content: string }
|
| { type: "token"; sessionId: string; content: string }
|
||||||
|
| { type: "final_answer"; sessionId: string; content: string }
|
||||||
| { type: "done"; sessionId: string; totalDurationMs?: number }
|
| { type: "done"; sessionId: string; totalDurationMs?: number }
|
||||||
| { type: "session_title"; sessionId: string; title: string }
|
| { type: "session_title"; sessionId: string; title: string }
|
||||||
|
| {
|
||||||
|
type: "activity_update";
|
||||||
|
sessionId: string;
|
||||||
|
activity: AgentActivity;
|
||||||
|
todos?: AgentTodoItem[];
|
||||||
|
todosCreatedAt?: number;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "progress";
|
type: "progress";
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -127,6 +164,8 @@ export type StreamEvent =
|
|||||||
permission: string;
|
permission: string;
|
||||||
patterns: string[];
|
patterns: string[];
|
||||||
target?: string;
|
target?: string;
|
||||||
|
activityId?: string;
|
||||||
|
reason?: string;
|
||||||
always: string[];
|
always: string[];
|
||||||
tool?: {
|
tool?: {
|
||||||
messageID: string;
|
messageID: string;
|
||||||
@@ -295,6 +334,47 @@ const normalizeTodos = (value: unknown): AgentTodoItem[] => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeActivityStatus = (value: unknown): AgentActivityStatus => {
|
||||||
|
if (value === "completed" || value === "error" || value === "cancelled") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "running";
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeActivity = (value: unknown): AgentActivity | undefined => {
|
||||||
|
if (!isObjectRecord(value) || typeof value.id !== "string") return undefined;
|
||||||
|
const now = Date.now();
|
||||||
|
const actions: AgentActivityAction[] = Array.isArray(value.actions)
|
||||||
|
? value.actions.filter(isObjectRecord).map((action, index) => ({
|
||||||
|
id: typeof action.id === "string" ? action.id : `${value.id}-action-${index}`,
|
||||||
|
tool: typeof action.tool === "string" ? action.tool : "tool",
|
||||||
|
title: typeof action.title === "string" ? action.title : "执行操作",
|
||||||
|
status: action.status === "completed" || action.status === "error"
|
||||||
|
? action.status
|
||||||
|
: "running",
|
||||||
|
target: typeof action.target === "string" ? action.target : undefined,
|
||||||
|
error: typeof action.error === "string" ? action.error : undefined,
|
||||||
|
startedAt: typeof action.started_at === "number" ? action.started_at : now,
|
||||||
|
endedAt: typeof action.ended_at === "number" ? action.ended_at : undefined,
|
||||||
|
elapsedMs: typeof action.elapsed_ms === "number" ? action.elapsed_ms : undefined,
|
||||||
|
elapsedSnapshotAt: typeof action.elapsed_ms === "number" ? now : undefined,
|
||||||
|
durationMs: typeof action.duration_ms === "number" ? action.duration_ms : undefined,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
id: value.id,
|
||||||
|
title: typeof value.title === "string" ? value.title : "正在处理",
|
||||||
|
reason: typeof value.reason === "string" ? value.reason : "",
|
||||||
|
status: normalizeActivityStatus(value.status),
|
||||||
|
actions,
|
||||||
|
startedAt: typeof value.started_at === "number" ? value.started_at : now,
|
||||||
|
endedAt: typeof value.ended_at === "number" ? value.ended_at : undefined,
|
||||||
|
elapsedMs: typeof value.elapsed_ms === "number" ? value.elapsed_ms : undefined,
|
||||||
|
elapsedSnapshotAt: typeof value.elapsed_ms === "number" ? now : undefined,
|
||||||
|
durationMs: typeof value.duration_ms === "number" ? value.duration_ms : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const emitParsedStreamEvent = (
|
const emitParsedStreamEvent = (
|
||||||
event: string,
|
event: string,
|
||||||
data: string,
|
data: string,
|
||||||
@@ -327,6 +407,7 @@ const emitParsedStreamEvent = (
|
|||||||
target?: string;
|
target?: string;
|
||||||
always?: unknown;
|
always?: unknown;
|
||||||
created_at?: number;
|
created_at?: number;
|
||||||
|
todos_created_at?: number;
|
||||||
reply?: PermissionReply;
|
reply?: PermissionReply;
|
||||||
questions?: unknown;
|
questions?: unknown;
|
||||||
answers?: unknown;
|
answers?: unknown;
|
||||||
@@ -335,6 +416,8 @@ const emitParsedStreamEvent = (
|
|||||||
todos?: unknown;
|
todos?: unknown;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
timeout_ms?: number;
|
timeout_ms?: number;
|
||||||
|
activity?: unknown;
|
||||||
|
activity_id?: string;
|
||||||
};
|
};
|
||||||
if (event === "state") {
|
if (event === "state") {
|
||||||
onEvent({
|
onEvent({
|
||||||
@@ -350,6 +433,12 @@ const emitParsedStreamEvent = (
|
|||||||
sessionId: parsed.session_id ?? "",
|
sessionId: parsed.session_id ?? "",
|
||||||
content: parsed.content ?? "",
|
content: parsed.content ?? "",
|
||||||
});
|
});
|
||||||
|
} else if (event === "final_answer") {
|
||||||
|
onEvent({
|
||||||
|
type: "final_answer",
|
||||||
|
sessionId: parsed.session_id ?? "",
|
||||||
|
content: parsed.content ?? "",
|
||||||
|
});
|
||||||
} else if (event === "progress") {
|
} else if (event === "progress") {
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "progress",
|
type: "progress",
|
||||||
@@ -364,6 +453,19 @@ const emitParsedStreamEvent = (
|
|||||||
elapsedMs: parsed.elapsed_ms,
|
elapsedMs: parsed.elapsed_ms,
|
||||||
durationMs: parsed.duration_ms,
|
durationMs: parsed.duration_ms,
|
||||||
});
|
});
|
||||||
|
} else if (event === "activity_update") {
|
||||||
|
const activity = normalizeActivity(parsed.activity);
|
||||||
|
if (activity) {
|
||||||
|
onEvent({
|
||||||
|
type: "activity_update",
|
||||||
|
sessionId: parsed.session_id ?? "",
|
||||||
|
activity,
|
||||||
|
todos: Array.isArray(parsed.todos)
|
||||||
|
? normalizeTodos(parsed.todos)
|
||||||
|
: undefined,
|
||||||
|
todosCreatedAt: parsed.todos_created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (event === "done") {
|
} else if (event === "done") {
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "done",
|
type: "done",
|
||||||
@@ -429,6 +531,8 @@ const emitParsedStreamEvent = (
|
|||||||
? parsed.patterns.filter((item): item is string => typeof item === "string")
|
? parsed.patterns.filter((item): item is string => typeof item === "string")
|
||||||
: [],
|
: [],
|
||||||
target: typeof parsed.target === "string" ? parsed.target : undefined,
|
target: typeof parsed.target === "string" ? parsed.target : undefined,
|
||||||
|
activityId: typeof parsed.activity_id === "string" ? parsed.activity_id : undefined,
|
||||||
|
reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
|
||||||
always: Array.isArray(parsed.always)
|
always: Array.isArray(parsed.always)
|
||||||
? parsed.always.filter((item): item is string => typeof item === "string")
|
? parsed.always.filter((item): item is string => typeof item === "string")
|
||||||
: [],
|
: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user