35 Commits

Author SHA1 Message Date
jiang 22afdbf2e8 fix(chat): 移除旧代码设计
Build Push and Deploy / docker-image (push) Successful in 3m42s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 20:25:48 +08:00
jiang ed9828befe fix(chat): hide actions while streaming
Build Push and Deploy / deploy-fallback-log (push) Has been cancelled
Build Push and Deploy / docker-image (push) Has been cancelled
2026-06-08 20:16:58 +08:00
jiang 968d798a2a fix(chat): hide raw permission metadata
Build Push and Deploy / docker-image (push) Failing after 42s
Build Push and Deploy / deploy-fallback-log (push) Successful in 0s
2026-06-08 20:12:08 +08:00
jiang 7da0ed0e39 fix(chat): mark aborted permissions
Build Push and Deploy / docker-image (push) Successful in 1m1s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 19:54:25 +08:00
jiang 166b45e529 fix(chat): normalize loaded messages
Build Push and Deploy / docker-image (push) Successful in 1m34s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 19:47:13 +08:00
jiang e5f13c3d46 fix(chat): remove regenerate action
Build Push and Deploy / docker-image (push) Successful in 1m7s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 19:33:06 +08:00
jiang 36cdb1df8d refactor(chat): split oversized chat modules 2026-06-08 19:23:46 +08:00
jiang 865e425748 feat(chat): refine shared todo card 2026-06-08 19:14:30 +08:00
jiang 3a36c693cd fix(chat): update question abort state 2026-06-08 18:39:45 +08:00
jiang b23cb6acdd fix(chat): wire question and todo cards 2026-06-08 18:10:28 +08:00
jiang 2691f42581 refactor: simplify chat fork flow
Build Push and Deploy / docker-image (push) Successful in 1m29s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 16:07:39 +08:00
jiang 34fd5bfb1a fix(chat): guard generated title events 2026-06-08 15:13:21 +08:00
jiang 40cc355fff fix(chat): 重新生成前撤销旧消息
Build Push and Deploy / docker-image (push) Successful in 1m47s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 14:38:52 +08:00
jiang f7cd5ebfa7 feat(chat): 添加权限批准模式切换 2026-06-08 14:14:52 +08:00
jiang d31565d52c fix(chat): 优化权限请求折叠状态
Build Push and Deploy / docker-image (push) Successful in 2m28s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 13:44:23 +08:00
jiang e32823e4b5 feat: add permission request UI
Build Push and Deploy / docker-image (push) Successful in 1m2s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-08 13:32:50 +08:00
jiang 5fc1812d53 fix(chat): 修复 abort 后 progress 仍显示工作中的问题
Build Push and Deploy / docker-image (push) Successful in 7s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-05 13:08:56 +08:00
jiang 709b029c4e fix(chat):建立连接前进行 token 有效性验证 2026-06-05 13:06:20 +08:00
jiang 57369772c7 fix(chat): 更新工具进度名称
Build Push and Deploy / docker-image (push) Successful in 1m17s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-04 18:02:38 +08:00
jiang 7764e25398 增加流式信息中断处理机制 2026-06-04 16:27:15 +08:00
jiang e60e1f6453 refactor: use backend chat sessions 2026-06-04 15:02:27 +08:00
jiang 20ca410e0a 新增 TurnList 组件,优化消息渲染逻辑
Build Push and Deploy / docker-image (push) Successful in 1m19s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-03 17:49:39 +08:00
jiang 06a3f32d2d 重构组件,优化性能并移除不必要的属性;撤销滚动条修改; 2026-06-03 16:58:10 +08:00
jiang fa3e6b6e84 输入框状态剥离,避免受长信息列表渲染影响;覆写滚动条状态动作,不再强制拉到最底 2026-06-03 15:01:24 +08:00
jiang 888132a60f 统一时间时区请求 2026-06-03 11:17:27 +08:00
jiang 9761ade8d8 新增应用样式 agent 工具
Build Push and Deploy / docker-image (push) Successful in 1m30s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-29 10:27:27 +08:00
jiang 0e82c080df 新增自动应用样式功能;强制计算后自动应用默认样式 2026-05-29 10:02:26 +08:00
jiang a4f0ffcd32 调整时间轴样式
Build Push and Deploy / docker-image (push) Successful in 2m0s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-28 17:11:08 +08:00
jiang 9dc8549f31 修复水流、等值线图层显示bug 2026-05-28 17:02:38 +08:00
jiang 6b447eb398 修复会话记录可能存储两次的bug;更改会话行为,默认进入新对话
Build Push and Deploy / docker-image (push) Successful in 1m1s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-22 14:19:14 +08:00
jiang 54fbf15be8 实现会话记录项目隔离
Build Push and Deploy / docker-image (push) Successful in 1m13s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-22 11:20:06 +08:00
jiang 4bf99e8069 Refine chat session storage and title handling
Build Push and Deploy / docker-image (push) Successful in 8s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-21 17:33:48 +08:00
jiang e4d45300b1 Fix missing chat session summary import
Build Push and Deploy / docker-image (push) Successful in 1m29s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-20 16:14:57 +08:00
jiang 477350a2a1 修复bug
Build Push and Deploy / docker-image (push) Failing after 41s
Build Push and Deploy / deploy-fallback-log (push) Successful in 0s
2026-05-20 15:43:35 +08:00
jiang 424555aae2 无对话的新对话不进入历史会话
Build Push and Deploy / docker-image (push) Failing after 41s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
2026-05-20 15:33:43 +08:00
45 changed files with 8241 additions and 3649 deletions
-7
View File
@@ -30,7 +30,6 @@
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"framer-motion": "^12.38.0",
"idb": "^8.0.3",
"js-cookie": "^3.0.5",
"next": "^16.1.6",
"next-auth": "^4.24.5",
@@ -15844,12 +15843,6 @@
"node": ">=0.10.0"
}
},
"node_modules/idb": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
-1
View File
@@ -39,7 +39,6 @@
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"framer-motion": "^12.38.0",
"idb": "^8.0.3",
"js-cookie": "^3.0.5",
"next": "^16.1.6",
"next-auth": "^4.24.5",
+139 -18
View File
@@ -26,46 +26,73 @@ import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
import BoltRounded from "@mui/icons-material/BoltRounded";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
import type { AgentModel } from "@/lib/chatStream";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
export type AgentComposerHandle = {
focus: () => void;
clear: () => void;
append: (text: string) => void;
setValue: (value: string) => void;
getValue: () => string;
};
type AgentComposerProps = {
input: string;
inputRef: React.RefObject<HTMLInputElement | null>;
isHydrating?: boolean;
isStreaming: boolean;
isListening: boolean;
isSttSupported: boolean;
presets: string[];
onInputChange: (value: string) => void;
onSend: () => void;
onSend: (prompt: string) => void;
onAbort: () => void;
onStartListening: () => void;
onStopListening: () => void;
onPresetSelect: (prompt: string) => void;
selectedModel: AgentModel;
onModelChange: (model: AgentModel) => void;
approvalMode: AgentApprovalMode;
onApprovalModeChange: (mode: AgentApprovalMode) => void;
};
export const AgentComposer = ({
input,
inputRef,
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
isHydrating = false,
isStreaming,
isListening,
isSttSupported,
presets,
onInputChange,
onSend,
onAbort,
onStartListening,
onStopListening,
onPresetSelect,
selectedModel,
onModelChange,
}: AgentComposerProps) => {
approvalMode,
onApprovalModeChange,
}, ref) {
const theme = useTheme();
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const [input, setInput] = React.useState("");
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
React.useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
clear: () => setInput(""),
append: (text: string) => setInput((prev) => prev + text),
setValue: (value: string) => setInput(value),
getValue: () => input,
}),
[input],
);
const handleSend = React.useCallback(() => {
const prompt = input.trim();
if (!prompt || isStreaming || isHydrating) return;
setInput("");
onSend(prompt);
}, [input, isHydrating, isStreaming, onSend]);
return (
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
@@ -121,8 +148,11 @@ export const AgentComposer = ({
size="medium"
clickable
onClick={() => {
onPresetSelect(prompt);
setInput(prompt);
setIsPresetOpen(false);
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}}
sx={{
height: 32,
@@ -165,11 +195,11 @@ export const AgentComposer = ({
<TextField
inputRef={inputRef}
value={input}
onChange={(event) => onInputChange(event.target.value)}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSend();
handleSend();
}
}}
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
@@ -221,6 +251,97 @@ export const AgentComposer = ({
</IconButton>
)
) : null}
<FormControl size="small" sx={{ minWidth: 96 }}>
<Select
value={approvalMode}
onChange={(event) =>
onApprovalModeChange(event.target.value as AgentApprovalMode)
}
disabled={isHydrating || isStreaming}
aria-label="权限批准模式"
renderValue={(val) => (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}>
{val === "always" ? (
<AdminPanelSettingsRounded sx={{ fontSize: 18, color: "inherit" }} />
) : (
<VerifiedUserRounded sx={{ fontSize: 18, color: "inherit" }} />
)}
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}>
{val === "always" ? "始终允许" : "请求批准"}
</Typography>
</Box>
)}
MenuProps={{
anchorOrigin: { vertical: "top", horizontal: "left" },
transformOrigin: { vertical: "bottom", horizontal: "left" },
sx: { zIndex: (theme) => theme.zIndex.modal + 110 },
PaperProps: {
sx: {
mb: 1.5,
width: 210,
borderRadius: 4,
bgcolor: alpha("#fff", 0.9),
backdropFilter: "blur(24px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 -12px 40px ${alpha("#000", 0.08)}`,
"& .MuiList-root": { p: 1 },
"& .MuiMenuItem-root": {
px: 1.5,
py: 1.2,
mb: 0.5,
borderRadius: 3,
alignItems: "flex-start",
"&:last-child": { mb: 0 },
"&.Mui-selected": {
bgcolor: alpha("#00acc1", 0.08),
"&:hover": { bgcolor: alpha("#00acc1", 0.12) },
"& .title": { color: "#00838f" },
"& .icon": { color: "#00acc1" },
},
},
},
},
}}
sx={{
height: 36,
borderRadius: "18px",
bgcolor: alpha("#fff", 0.6),
color: "text.secondary",
".MuiOutlinedInput-notchedOutline": { border: "none" },
".MuiSelect-select": {
py: 0,
pl: 1,
pr: "28px !important",
minHeight: 36,
display: "flex",
alignItems: "center",
},
"&:hover, &:has(.MuiSelect-select[aria-expanded=\"true\"])": {
bgcolor: alpha("#000", 0.06),
color: "text.primary",
},
".MuiSelect-icon": {
color: "text.secondary",
right: 4,
},
}}
>
<MenuItem value="request">
<VerifiedUserRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
<Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}></Typography>
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography>
</Box>
</MenuItem>
<MenuItem value="always">
<AdminPanelSettingsRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
<Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}></Typography>
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography>
</Box>
</MenuItem>
</Select>
</FormControl>
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
@@ -362,7 +483,7 @@ export const AgentComposer = ({
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
<IconButton
disabled={!canSend}
onClick={onSend}
onClick={handleSend}
aria-label="发送"
size="small"
sx={{
@@ -397,4 +518,4 @@ export const AgentComposer = ({
</Box>
</Box>
);
};
});
-40
View File
@@ -1,40 +0,0 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { AgentHeader } from "./AgentHeader";
jest.mock("next/image", () => ({
__esModule: true,
default: (props: React.ComponentProps<"img">) => <img {...props} alt={props.alt ?? ""} />,
}));
const renderWithTheme = (ui: React.ReactElement) =>
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
describe("AgentHeader", () => {
it("submits a renamed active session title", () => {
const onRenameSessionTitle = jest.fn();
renderWithTheme(
<AgentHeader
sessionTitle="原始标题"
canRenameSessionTitle
isStreaming={false}
isHistoryOpen={false}
onHistoryToggle={jest.fn()}
onRenameSessionTitle={onRenameSessionTitle}
onNewConversation={jest.fn()}
onClose={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "修改对话标题" }));
fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), {
target: { value: "更新后的标题" },
});
fireEvent.click(screen.getByLabelText("确认"));
expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题");
});
});
+1 -1
View File
@@ -44,7 +44,7 @@ export const AgentHeader = ({
onClose,
}: AgentHeaderProps) => {
const theme = useTheme();
const displayTitle = sessionTitle?.trim() || "TJWater Agent";
const displayTitle = sessionTitle?.trim() || "新对话";
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || "");
@@ -165,9 +165,6 @@ export const AgentHistoryPanel = ({
<Typography variant="subtitle2" fontWeight={800} color="text.primary">
</Typography>
<Typography variant="caption" color="text.secondary">
</Typography>
</Box>
<Tooltip title="新建对话">
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
@@ -0,0 +1,27 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
export const MarkdownBlock = ({ children }: { children: string }) => {
const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
const selectedText = window.getSelection()?.toString();
if (!selectedText) return;
event.preventDefault();
event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText));
}, []);
return (
<div className={markdownStyles.markdown} onCopy={handleCopy}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
</div>
);
};
@@ -0,0 +1,605 @@
"use client";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Box,
Button,
Chip,
CircularProgress,
Collapse,
IconButton,
Stack,
Typography,
alpha,
useTheme,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
import TerminalRounded from "@mui/icons-material/TerminalRounded";
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import BlockRounded from "@mui/icons-material/BlockRounded";
import PushPinRounded from "@mui/icons-material/PushPinRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import type { PermissionReply } from "@/lib/chatStream";
import type { Message } from "./GlobalChatbox.types";
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
if (permission.permission === "external_directory") return "访问工作区外目录";
if (permission.permission === "bash") return "执行终端命令";
if (permission.permission === "edit") return "修改文件内容";
return permission.permission || "工具权限请求";
};
const getPermissionPrimaryValue = (
permission: NonNullable<Message["permissions"]>[number],
) => {
if (typeof permission.target === "string" && permission.target.trim()) {
return permission.target.trim();
}
return permission.patterns[0] ?? permission.permission;
};
const PermissionIcon = ({
permission,
}: {
permission: NonNullable<Message["permissions"]>[number];
}) => {
if (permission.permission === "bash") {
return <TerminalRounded sx={{ fontSize: 22 }} />;
}
if (permission.permission === "external_directory") {
return <FolderOpenRounded sx={{ fontSize: 22 }} />;
}
return <VerifiedUserRounded sx={{ fontSize: 22 }} />;
};
const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => {
if (status === "approved_always") return "已始终允许";
if (status === "approved_once") return "已允许一次";
if (status === "rejected") return "已拒绝";
if (status === "aborted") return "已中断";
if (status === "error") return "提交失败";
if (status === "submitting") return "提交中";
return "等待确认";
};
const pendingPermissionColor = "#f9a825";
const approvedOncePermissionColor = "#00838f";
const getPermissionStatusColor = (
status: NonNullable<Message["permissions"]>[number]["status"],
theme: Theme,
) => {
if (status === "approved_once") return approvedOncePermissionColor;
if (status === "approved_always") return theme.palette.success.main;
if (status === "rejected" || status === "error") return theme.palette.error.main;
if (status === "aborted") return theme.palette.text.secondary;
return pendingPermissionColor;
};
const getPermissionStatusTextColor = (
status: NonNullable<Message["permissions"]>[number]["status"],
theme: Theme,
) => {
if (status === "approved_once") return "#006c78";
if (status === "approved_always") return theme.palette.success.dark;
if (status === "rejected" || status === "error") return theme.palette.error.main;
if (status === "aborted") return theme.palette.text.secondary;
return "#8a5a00";
};
const PermissionRequestCard = ({
permission,
isRunning,
onReply,
}: {
permission: NonNullable<Message["permissions"]>[number];
isRunning: boolean;
onReply: (requestId: string, reply: PermissionReply) => void;
}) => {
const theme = useTheme();
const isPending =
isRunning && (permission.status === "pending" || permission.status === "error");
const isSubmitting = isRunning && permission.status === "submitting";
const primaryValue = getPermissionPrimaryValue(permission);
const accentColor = getPermissionStatusColor(permission.status, theme);
const statusTextColor = getPermissionStatusTextColor(permission.status, theme);
const statusLabel = getPermissionStatusLabel(permission.status);
return (
<Box
sx={{
borderRadius: 3,
overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`,
bgcolor: alpha("#fff", 0.5),
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
backdropFilter: "blur(20px)",
position: "relative",
"&::before": {
content: '""',
position: "absolute",
inset: "10px auto 10px 0",
width: 3,
borderRadius: "0 999px 999px 0",
bgcolor: accentColor,
},
}}
>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{
px: 1.5,
py: 1.25,
pl: 1.75,
borderBottom: `1px solid ${alpha("#000", 0.05)}`,
}}
>
<Box
sx={{
width: 32,
height: 32,
borderRadius: "50%",
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: accentColor,
bgcolor: alpha(accentColor, 0.1),
border: `1px solid ${alpha(accentColor, 0.16)}`,
}}
>
<PermissionIcon permission={permission} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
{getPermissionTitle(permission)}
</Typography>
</Box>
<Chip
size="small"
label={statusLabel}
sx={{
height: 24,
fontSize: "0.7rem",
fontWeight: 800,
borderRadius: "12px",
bgcolor: alpha(accentColor, 0.12),
color: statusTextColor,
"& .MuiChip-label": { px: 1 },
}}
/>
</Stack>
<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("#000", 0.025),
border: `1px solid ${alpha("#000", 0.045)}`,
}}
>
<Typography variant="caption" color="text.secondary" fontWeight={800}>
</Typography>
<Typography
variant="body2"
color="text.primary"
fontFamily={permission.permission === "bash" ? "monospace" : undefined}
sx={{
mt: 0.25,
lineHeight: 1.55,
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}}
>
{primaryValue}
</Typography>
</Box>
</Stack>
{permission.error ? (
<Box sx={{ px: 1.5, pb: isPending || isSubmitting ? 1 : 1.35, pl: 1.75 }}>
<Typography
variant="caption"
color="error.main"
sx={{
display: "block",
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha(theme.palette.error.main, 0.06),
wordBreak: "break-word",
}}
>
{permission.error}
</Typography>
</Box>
) : null}
{isPending || isSubmitting ? (
<Stack
direction="row"
spacing={1}
flexWrap="wrap"
useFlexGap
sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }}
>
<Button
size="small"
variant="contained"
disableElevation
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "once")}
startIcon={
isSubmitting ? (
<CircularProgress size={14} color="inherit" />
) : (
<CheckCircleRounded fontSize="small" />
)
}
sx={{
minWidth: 94,
height: 34,
borderRadius: "17px",
bgcolor: "#00838f",
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`,
"&:hover": {
bgcolor: "#006c78",
boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`,
},
}}
>
</Button>
<Button
size="small"
variant="outlined"
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "always")}
startIcon={<PushPinRounded fontSize="small" />}
sx={{
height: 34,
borderRadius: "17px",
px: 1.5,
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
color: "#00838f",
borderColor: alpha("#00838f", 0.24),
bgcolor: alpha("#fff", 0.45),
"&:hover": {
borderColor: alpha("#00838f", 0.36),
bgcolor: alpha("#00838f", 0.08),
},
}}
>
</Button>
<Button
size="small"
color="error"
variant="outlined"
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "reject")}
startIcon={<BlockRounded fontSize="small" />}
sx={{
height: 34,
borderRadius: "17px",
px: 1.5,
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
borderColor: alpha(theme.palette.error.main, 0.22),
bgcolor: alpha("#fff", 0.45),
"&:hover": {
borderColor: alpha(theme.palette.error.main, 0.34),
bgcolor: alpha(theme.palette.error.main, 0.07),
},
}}
>
</Button>
</Stack>
) : null}
</Box>
);
};
export const PermissionRequestGroup = ({
permissions,
isRunning,
onReply,
}: {
permissions: NonNullable<Message["permissions"]>;
isRunning: boolean;
onReply: (requestId: string, reply: PermissionReply) => void;
}) => {
const theme = useTheme();
const onceCount = permissions.filter((permission) => permission.status === "approved_once").length;
const alwaysCount = permissions.filter((permission) => permission.status === "approved_always").length;
const rejectedCount = permissions.filter((permission) => permission.status === "rejected").length;
const abortedCount = permissions.filter((permission) => permission.status === "aborted").length;
const pendingCount = permissions.filter(
(permission) =>
permission.status === "pending" ||
permission.status === "submitting" ||
permission.status === "error",
).length;
const hasPendingPermissions = pendingCount > 0;
const [expanded, setExpanded] = React.useState(false);
const latestPermissions = permissions.slice(-3);
const pendingPermissions = permissions.filter(
(permission) =>
permission.status === "pending" ||
permission.status === "submitting" ||
permission.status === "error",
);
const summaryItems = [
{ label: "共", value: permissions.length, color: theme.palette.text.secondary },
{ label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) },
{ label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) },
{ label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) },
{ label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) },
];
const chipColor =
pendingCount > 0
? getPermissionStatusColor("pending", theme)
: abortedCount > 0
? getPermissionStatusColor("aborted", theme)
: rejectedCount > 0
? getPermissionStatusColor("rejected", theme)
: getPermissionStatusColor("approved_always", theme);
const chipTextColor =
pendingCount > 0
? getPermissionStatusTextColor("pending", theme)
: abortedCount > 0
? getPermissionStatusTextColor("aborted", theme)
: rejectedCount > 0
? getPermissionStatusTextColor("rejected", theme)
: getPermissionStatusTextColor("approved_always", theme);
return (
<Box
sx={{
borderRadius: 3,
overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`,
bgcolor: alpha("#fff", 0.46),
boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`,
backdropFilter: "blur(20px)",
}}
>
<Stack
direction="row"
alignItems="center"
spacing={1}
role="button"
tabIndex={0}
onClick={() => setExpanded((value) => !value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((value) => !value);
}
}}
sx={{
px: 1.5,
py: 1.15,
cursor: "pointer",
transition: "background-color 0.2s ease",
"&:hover": { bgcolor: alpha("#000", 0.025) },
}}
>
<Box
sx={{
width: 30,
height: 30,
borderRadius: "50%",
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: chipColor,
bgcolor: alpha(chipColor, 0.1),
border: `1px solid ${alpha(chipColor, 0.15)}`,
}}
>
<VerifiedUserRounded sx={{ fontSize: 18 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
</Typography>
<Stack
direction="row"
flexWrap="wrap"
gap={0.6}
sx={{ mt: 0.55, maxHeight: 48, overflow: "hidden" }}
>
{summaryItems.map((item) => (
<Box
key={item.label}
component="span"
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.45,
height: 22,
px: 0.8,
borderRadius: "11px",
bgcolor: alpha(item.color, 0.08),
border: `1px solid ${alpha(item.color, 0.12)}`,
color: "textColor" in item ? item.textColor : item.color,
fontSize: "0.7rem",
fontWeight: 800,
lineHeight: 1,
whiteSpace: "nowrap",
}}
>
<Box
component="span"
sx={{
color: "textColor" in item ? item.textColor : item.color,
fontWeight: 700,
}}
>
{item.label}
</Box>
<Box component="span">{item.value} </Box>
</Box>
))}
</Stack>
</Box>
{isRunning && pendingCount > 0 ? (
<Chip
size="small"
label={`待确认 ${pendingCount}`}
sx={{
height: 24,
borderRadius: "12px",
fontSize: "0.7rem",
fontWeight: 800,
color: chipTextColor,
bgcolor: alpha(chipColor, 0.1),
"& .MuiChip-label": { px: 1 },
}}
/>
) : null}
<IconButton
size="small"
aria-label={expanded ? "收起权限请求" : "展开权限请求"}
sx={{
width: 28,
height: 28,
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>
{!expanded && isRunning && !hasPendingPermissions && latestPermissions.length > 0 ? (
<Stack spacing={0} sx={{ px: 1.5, pb: 1.25 }}>
{latestPermissions.map((permission, index) => {
const primaryValue = getPermissionPrimaryValue(permission);
const isLast = index === latestPermissions.length - 1;
const itemColor = getPermissionStatusColor(permission.status, theme);
const itemTextColor = getPermissionStatusTextColor(permission.status, theme);
return (
<Stack
key={permission.requestId}
direction="row"
spacing={1}
alignItems="center"
sx={{
py: 0.8,
borderTop: index === 0 ? `1px solid ${alpha(chipColor, 0.1)}` : "none",
borderBottom: isLast ? "none" : `1px solid ${alpha("#000", 0.045)}`,
}}
>
<Box
sx={{
width: 24,
height: 24,
borderRadius: "50%",
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: itemColor,
bgcolor: alpha(itemColor, 0.08),
}}
>
<PermissionIcon permission={permission} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="caption" color="text.primary" fontWeight={750} noWrap sx={{ display: "block" }}>
{getPermissionTitle(permission)}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
title={primaryValue}
sx={{
display: "block",
fontFamily: permission.permission === "bash" ? "monospace" : undefined,
}}
>
{primaryValue}
</Typography>
</Box>
<Chip
size="small"
label={getPermissionStatusLabel(permission.status)}
sx={{
height: 22,
borderRadius: "11px",
fontSize: "0.68rem",
fontWeight: 800,
color: itemTextColor,
bgcolor: alpha(itemColor, 0.08),
"& .MuiChip-label": { px: 0.85 },
}}
/>
</Stack>
);
})}
</Stack>
) : null}
<AnimatePresence initial={false}>
{!expanded && isRunning && hasPendingPermissions ? (
<motion.div
key="pending-permissions"
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -8, height: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ overflow: "hidden" }}
>
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
{pendingPermissions.map((permission) => (
<PermissionRequestCard
key={permission.requestId}
permission={permission}
isRunning={isRunning}
onReply={onReply}
/>
))}
</Stack>
</motion.div>
) : null}
</AnimatePresence>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
{permissions.map((permission) => (
<PermissionRequestCard
key={permission.requestId}
permission={permission}
isRunning={isRunning}
onReply={onReply}
/>
))}
</Stack>
</Collapse>
</Box>
);
};
@@ -30,8 +30,8 @@ describe("AgentProgressTimeline", () => {
id: "tool",
phase: "tool",
status: "running",
title: "正在调用 dynamic_http_call",
detail: "GET /api/v1/network/bottlenecks",
title: "正在调用 tjwater_cli",
detail: "analysis bottlenecks",
startedAt: now - 1200,
elapsedMs: 1200,
elapsedSnapshotAt: now,
@@ -43,7 +43,7 @@ describe("AgentProgressTimeline", () => {
expect(screen.getByText(/Agent 过程:/)).toBeInTheDocument();
expect(screen.getByText(/耗时 5.0s/)).toBeInTheDocument();
expect(screen.getByText("查询后端数据")).toBeInTheDocument();
expect(screen.getByText("GET /api/v1/network/bottlenecks")).toBeInTheDocument();
expect(screen.getByText("analysis bottlenecks")).toBeInTheDocument();
expect(screen.getByText("1.2s")).toBeInTheDocument();
});
@@ -86,7 +86,7 @@ describe("AgentProgressTimeline", () => {
id: "tool",
phase: "tool",
status: "completed",
title: "正在调用 dynamic_http_call",
title: "正在调用 tjwater_cli",
startedAt: Date.now() - 4000,
endedAt: Date.now(),
},
+16 -2
View File
@@ -76,7 +76,7 @@ const phaseIcon = (phase: string, status: ChatProgress["status"]) => {
const formatToolTitle = (item: ChatProgress) => {
const text = `${item.title} ${item.detail ?? ""}`;
if (text.includes("dynamic_http_call")) return "查询后端数据";
if (text.includes("tjwater_cli")) return "查询后端数据";
if (text.includes("show_chart")) return "生成图表";
if (text.includes("locate_features")) return "地图定位";
if (text.includes("view_history")) return "打开历史曲线";
@@ -85,7 +85,12 @@ const formatToolTitle = (item: ChatProgress) => {
return item.title;
};
export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => {
type AgentProgressTimelineProps = {
progress: ChatProgress[];
isAborted?: boolean;
};
const AgentProgressTimelineInner = ({ progress, isAborted }: AgentProgressTimelineProps) => {
const theme = useTheme();
const [nowMs, setNowMs] = useState(() => Date.now());
@@ -356,3 +361,12 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
</Box>
);
};
export const AgentProgressTimeline = React.memo(
AgentProgressTimelineInner,
(prevProps, nextProps) =>
prevProps.progress === nextProps.progress &&
prevProps.isAborted === nextProps.isAborted,
);
AgentProgressTimeline.displayName = "AgentProgressTimeline";
@@ -0,0 +1,564 @@
"use client";
import React from "react";
import {
Box,
Button,
Checkbox,
Chip,
CircularProgress,
Collapse,
FormControlLabel,
Stack,
TextField,
Typography,
alpha,
useTheme,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import HelpOutlineRounded from "@mui/icons-material/HelpOutlineRounded";
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
import type { Message } from "./GlobalChatbox.types";
const getQuestionStatusLabel = (
status: NonNullable<Message["questions"]>[number]["status"],
) => {
if (status === "answered") return "已回答";
if (status === "rejected") return "已跳过";
if (status === "error") return "提交失败";
if (status === "submitting") return "提交中";
return "等待回答";
};
const getQuestionStatusColor = (
status: NonNullable<Message["questions"]>[number]["status"],
theme: Theme,
) => {
if (status === "answered") return theme.palette.success.main;
if (status === "rejected") return theme.palette.text.secondary;
if (status === "error") return theme.palette.error.main;
return "#0288d1";
};
const QuestionRequestCard = ({
questionRequest,
onReply,
onReject,
}: {
questionRequest: NonNullable<Message["questions"]>[number];
onReply: (requestId: string, answers: string[][]) => void;
onReject: (requestId: string) => void;
}) => {
const theme = useTheme();
const isEditable =
questionRequest.status === "pending" || questionRequest.status === "error";
const isSubmitting = questionRequest.status === "submitting";
const statusColor = getQuestionStatusColor(questionRequest.status, theme);
const [selected, setSelected] = React.useState<Record<number, string[]>>({});
const [customSelected, setCustomSelected] = React.useState<Record<number, boolean>>({});
const [custom, setCustom] = React.useState<Record<number, string>>({});
const answers = React.useMemo(
() =>
questionRequest.questions.map((question, index) => {
const selectedAnswers = selected[index] ?? [];
const isCustomSelected =
customSelected[index] === true ||
(question.custom !== false && question.options.length === 0);
const customAnswer = custom[index]?.trim();
return isCustomSelected && customAnswer
? [...selectedAnswers, customAnswer]
: selectedAnswers;
}),
[custom, customSelected, questionRequest.questions, selected],
);
const canSubmit =
isEditable &&
questionRequest.questions.length > 0 &&
questionRequest.questions.every((_, index) => {
const answer = answers[index] ?? [];
return answer.some((item) => item.trim().length > 0);
});
const answerSummary = (questionRequest.answers ?? [])
.map((answer) => answer.join("、"))
.filter(Boolean)
.join("");
return (
<Box
sx={{
borderRadius: 3,
overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`,
bgcolor: alpha("#fff", 0.52),
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
backdropFilter: "blur(20px)",
position: "relative",
"&::before": {
content: '""',
position: "absolute",
inset: "10px auto 10px 0",
width: 3,
borderRadius: "0 999px 999px 0",
bgcolor: statusColor,
},
}}
>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{
px: 1.5,
py: 1.25,
pl: 1.75,
borderBottom: `1px solid ${alpha("#000", 0.05)}`,
}}
>
<Box
sx={{
width: 32,
height: 32,
borderRadius: "50%",
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: statusColor,
bgcolor: alpha(statusColor, 0.1),
border: `1px solid ${alpha(statusColor, 0.16)}`,
}}
>
<HelpOutlineRounded sx={{ fontSize: 21 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
</Typography>
</Box>
<Chip
size="small"
label={getQuestionStatusLabel(questionRequest.status)}
sx={{
height: 24,
fontSize: "0.7rem",
fontWeight: 800,
borderRadius: "12px",
bgcolor: alpha(statusColor, 0.12),
color: statusColor,
"& .MuiChip-label": { px: 1 },
}}
/>
</Stack>
<Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}>
{questionRequest.questions.map((question, index) => {
const selectedAnswers = selected[index] ?? [];
const isCustomEnabled = question.custom !== false;
const isCustomSelected =
customSelected[index] === true ||
(isCustomEnabled && question.options.length === 0);
const setQuestionAnswers = (nextAnswers: string[]) => {
setSelected((current) => ({
...current,
[index]: nextAnswers,
}));
};
const setQuestionCustomSelected = (checked: boolean) => {
setCustomSelected((current) => ({
...current,
[index]: checked,
}));
};
return (
<Box
key={`${question.header}-${index}`}
sx={{
px: 1.25,
py: 1,
borderRadius: 2.5,
bgcolor: alpha("#000", 0.025),
border: `1px solid ${alpha("#000", 0.045)}`,
}}
>
<Typography variant="caption" color="text.secondary" fontWeight={800}>
{question.header || `问题 ${index + 1}`}
</Typography>
<Typography
variant="body2"
color="text.primary"
sx={{ mt: 0.35, lineHeight: 1.55, wordBreak: "break-word" }}
>
{question.question}
</Typography>
{question.options.length ? (
<Stack spacing={0.75} sx={{ mt: 1 }}>
{question.options.map((option) => {
const checked = selectedAnswers.includes(option.label);
if (question.multiple) {
return (
<FormControlLabel
key={option.label}
disabled={!isEditable || isSubmitting}
control={
<Checkbox
size="small"
checked={checked}
onChange={(event) => {
if (event.target.checked) {
setQuestionAnswers([...selectedAnswers, option.label]);
} else {
setQuestionAnswers(
selectedAnswers.filter((item) => item !== option.label),
);
}
}}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight={750}>
{option.label}
</Typography>
{option.description ? (
<Typography variant="caption" color="text.secondary">
{option.description}
</Typography>
) : null}
</Box>
}
sx={{ alignItems: "flex-start", m: 0 }}
/>
);
}
return (
<Button
key={option.label}
size="small"
variant={checked ? "contained" : "outlined"}
disabled={!isEditable || isSubmitting}
onClick={() => {
setQuestionAnswers([option.label]);
setQuestionCustomSelected(false);
}}
startIcon={
checked ? (
<CheckCircleRounded fontSize="small" />
) : (
<RadioButtonUncheckedRounded fontSize="small" />
)
}
sx={{
justifyContent: "flex-start",
minHeight: 38,
borderRadius: 2,
textTransform: "none",
fontWeight: 800,
bgcolor: checked ? "#0288d1" : alpha("#fff", 0.45),
borderColor: checked ? "#0288d1" : alpha("#0288d1", 0.22),
"&:hover": {
bgcolor: checked ? "#0277bd" : alpha("#0288d1", 0.08),
},
}}
>
<Box sx={{ textAlign: "left", minWidth: 0 }}>
<Typography variant="body2" fontWeight={800}>
{option.label}
</Typography>
{option.description ? (
<Typography
variant="caption"
sx={{ display: "block", opacity: checked ? 0.86 : 0.72 }}
>
{option.description}
</Typography>
) : null}
</Box>
</Button>
);
})}
{isCustomEnabled ? (
question.multiple ? (
<FormControlLabel
disabled={!isEditable || isSubmitting}
control={
<Checkbox
size="small"
checked={isCustomSelected}
onChange={(event) =>
setQuestionCustomSelected(event.target.checked)
}
sx={{
p: 0.5,
color: alpha("#0288d1", 0.55),
"&.Mui-checked": { color: "#0288d1" },
}}
/>
}
label={
<Stack direction="row" spacing={0.75} alignItems="center">
<EditNoteRounded sx={{ fontSize: 18, color: "#0288d1" }} />
<Typography variant="body2" fontWeight={800}>
</Typography>
</Stack>
}
sx={{
alignItems: "center",
minHeight: 38,
m: 0,
px: 0.75,
py: 0.25,
borderRadius: 2,
border: `1px solid ${
isCustomSelected ? "#0288d1" : alpha("#0288d1", 0.18)
}`,
bgcolor: isCustomSelected
? alpha("#0288d1", 0.1)
: alpha("#fff", 0.45),
transition: "background-color 0.18s ease, border-color 0.18s ease",
"&:hover": {
bgcolor: isCustomSelected
? alpha("#0288d1", 0.13)
: alpha("#0288d1", 0.07),
},
"& .MuiFormControlLabel-label": {
color: isCustomSelected ? "#0277bd" : "text.primary",
},
}}
/>
) : (
<Button
size="small"
variant={isCustomSelected ? "contained" : "outlined"}
disabled={!isEditable || isSubmitting}
onClick={() => {
setQuestionAnswers([]);
setQuestionCustomSelected(true);
}}
startIcon={
isCustomSelected ? (
<CheckCircleRounded fontSize="small" />
) : (
<EditNoteRounded fontSize="small" />
)
}
sx={{
justifyContent: "flex-start",
minHeight: 38,
borderRadius: 2,
textTransform: "none",
fontWeight: 800,
bgcolor: isCustomSelected ? "#0288d1" : alpha("#fff", 0.45),
borderColor: isCustomSelected
? "#0288d1"
: alpha("#0288d1", 0.22),
"&:hover": {
bgcolor: isCustomSelected
? "#0277bd"
: alpha("#0288d1", 0.08),
},
}}
>
<Box sx={{ textAlign: "left", minWidth: 0 }}>
<Typography variant="body2" fontWeight={800}>
</Typography>
</Box>
</Button>
)
) : null}
</Stack>
) : null}
<Collapse in={isCustomEnabled && isCustomSelected} timeout="auto" unmountOnExit>
<Box
sx={{
mt: 0.85,
px: 1.15,
py: 0.85,
borderRadius: 2.5,
bgcolor: alpha("#fff", 0.62),
border: `1px solid ${alpha("#fff", 0.82)}`,
boxShadow: `0 8px 22px ${alpha("#000", 0.045)}, 0 0 0 1px ${alpha("#0288d1", 0.05)} inset`,
backdropFilter: "blur(18px)",
}}
>
<TextField
multiline
minRows={2}
maxRows={5}
fullWidth
variant="standard"
disabled={!isEditable || isSubmitting}
value={custom[index] ?? ""}
onChange={(event) =>
setCustom((current) => ({
...current,
[index]: event.target.value,
}))
}
placeholder="输入自定义回答"
InputProps={{
disableUnderline: true,
sx: {
alignItems: "flex-start",
fontSize: "0.88rem",
lineHeight: 1.55,
fontWeight: 500,
color: "text.primary",
"& textarea::placeholder": {
color: alpha(theme.palette.text.primary, 0.38),
opacity: 1,
},
},
}}
/>
</Box>
</Collapse>
</Box>
);
})}
{questionRequest.status === "answered" ? (
<Typography
variant="caption"
color="success.main"
sx={{
display: "block",
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha(theme.palette.success.main, 0.07),
wordBreak: "break-word",
}}
>
{answerSummary ? `${answerSummary}` : ""}
</Typography>
) : null}
{questionRequest.status === "rejected" ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha("#000", 0.035),
}}
>
</Typography>
) : null}
{questionRequest.error ? (
<Typography
variant="caption"
color="error.main"
sx={{
display: "block",
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha(theme.palette.error.main, 0.06),
wordBreak: "break-word",
}}
>
{questionRequest.error}
</Typography>
) : null}
</Stack>
{isEditable || isSubmitting ? (
<Stack
direction="row"
spacing={1}
flexWrap="wrap"
useFlexGap
sx={{ px: 1.5, pb: 1.35, pl: 1.75 }}
>
<Button
size="small"
variant="outlined"
disabled={isSubmitting}
onClick={() => onReject(questionRequest.requestId)}
sx={{
height: 34,
borderRadius: "17px",
px: 1.5,
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
color: "text.secondary",
borderColor: alpha(theme.palette.text.secondary, 0.22),
bgcolor: alpha("#fff", 0.45),
}}
>
</Button>
<Button
size="small"
variant="contained"
disableElevation
disabled={!canSubmit || isSubmitting}
onClick={() => onReply(questionRequest.requestId, answers)}
startIcon={
isSubmitting ? (
<CircularProgress size={14} color="inherit" />
) : (
<CheckCircleRounded fontSize="small" />
)
}
sx={{
minWidth: 104,
height: 34,
borderRadius: "17px",
bgcolor: "#0288d1",
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
boxShadow: `0 4px 12px ${alpha("#0288d1", 0.24)}`,
"&:hover": {
bgcolor: "#0277bd",
boxShadow: `0 6px 16px ${alpha("#0288d1", 0.28)}`,
},
}}
>
</Button>
</Stack>
) : null}
</Box>
);
};
export const QuestionRequestGroup = ({
questions,
onReply,
onReject,
}: {
questions: NonNullable<Message["questions"]>;
onReply: (requestId: string, answers: string[][]) => void;
onReject: (requestId: string) => void;
}) => (
<Stack spacing={1}>
{questions.map((question) => (
<QuestionRequestCard
key={question.requestId}
questionRequest={question}
onReply={onReply}
onReject={onReject}
/>
))}
</Stack>
);
+308
View File
@@ -0,0 +1,308 @@
"use client";
import React from "react";
import {
Box,
Chip,
CircularProgress,
Collapse,
IconButton,
Stack,
Typography,
alpha,
useTheme,
} from "@mui/material";
import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import BlockRounded from "@mui/icons-material/BlockRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
import type { Message } from "./GlobalChatbox.types";
export const TodoPlanCard = ({
todoUpdate,
}: {
todoUpdate: NonNullable<Message["todos"]>;
}) => {
const theme = useTheme();
const total = todoUpdate.todos.length;
const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length;
const running = todoUpdate.todos.find((todo) => todo.status === "in_progress");
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length;
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
const isAborted = cancelled > 0 && completed + cancelled === total;
const canCollapse = total > 4;
const [expanded, setExpanded] = React.useState(!canCollapse && !isAborted);
const pinnedTodos = canCollapse ? todoUpdate.todos.slice(0, 4) : todoUpdate.todos;
const collapsibleTodos = canCollapse ? todoUpdate.todos.slice(4) : [];
const hiddenCount = expanded ? 0 : collapsibleTodos.length;
const latestUpdatedAt = Math.max(
todoUpdate.createdAt,
...todoUpdate.todos
.map((todo) => todo.updatedAt ?? todo.createdAt ?? 0)
.filter((value) => value > 0),
);
const updatedAtLabel =
latestUpdatedAt > 0
? new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(latestUpdatedAt))
: undefined;
const getTodoVisual = (status: NonNullable<Message["todos"]>["todos"][number]["status"]) => {
if (status === "completed") {
return { icon: <CheckCircleRounded sx={{ fontSize: 17 }} />, color: theme.palette.success.main, label: "完成" };
}
if (status === "in_progress") {
return { icon: <CircularProgress size={15} thickness={5} />, color: "#0288d1", label: "进行中" };
}
if (status === "cancelled") {
return { icon: <BlockRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.disabled, label: "中止" };
}
return { icon: <RadioButtonUncheckedRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.secondary, label: "待办" };
};
const getPriorityLabel = (priority: NonNullable<Message["todos"]>["todos"][number]["priority"]) => {
if (priority === "high") return { label: "高优先级", color: "#8a5a00" };
if (priority === "medium") return { label: "中优先级", color: "#9a6a16" };
if (priority === "low") return { label: "低优先级", color: "#8d7960" };
return undefined;
};
const statusSummary = isAborted
? `${completed} 完成 / ${cancelled} 中止`
: [
completed ? `${completed} 完成` : null,
running ? "1 进行中" : null,
pending ? `${pending} 待办` : null,
cancelled ? `${cancelled} 中止` : null,
].filter(Boolean).join(" / ") || "等待任务";
const renderTodoRow = (
todo: NonNullable<Message["todos"]>["todos"][number],
index: number,
) => {
const visual = getTodoVisual(todo.status);
const priority = getPriorityLabel(todo.priority);
return (
<Stack
key={`${todo.id}-${index}`}
direction="row"
alignItems="flex-start"
spacing={1}
sx={{
py: 0.8,
borderTop: `1px solid ${alpha("#00838f", 0.08)}`,
color: todo.status === "cancelled" ? "text.disabled" : "text.primary",
}}
>
<Box
sx={{
width: 24,
height: 24,
borderRadius: 1.25,
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: visual.color,
bgcolor: alpha(visual.color, 0.08),
mt: 0.1,
}}
>
{visual.icon}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="body2"
sx={{
minWidth: 0,
wordBreak: "break-word",
lineHeight: 1.45,
textDecoration: todo.status === "cancelled" ? "line-through" : undefined,
}}
>
{todo.content}
</Typography>
</Box>
<Stack direction="row" spacing={0.5} sx={{ flex: "0 0 auto" }}>
{priority ? (
<Chip
size="small"
label={priority.label}
sx={{
height: 22,
borderRadius: "11px",
fontSize: "0.66rem",
fontWeight: 800,
color: priority.color,
bgcolor: alpha(priority.color, 0.045),
border: `1px solid ${alpha(priority.color, 0.16)}`,
"& .MuiChip-label": { px: 0.75 },
}}
/>
) : null}
<Chip
size="small"
label={visual.label}
sx={{
height: 22,
borderRadius: "11px",
fontSize: "0.66rem",
fontWeight: 800,
color: visual.color,
bgcolor: alpha(visual.color, 0.08),
"& .MuiChip-label": { px: 0.75 },
}}
/>
</Stack>
</Stack>
);
};
if (total === 0) {
return null;
}
return (
<Box
sx={{
borderRadius: 2,
overflow: "hidden",
border: `1px solid ${alpha("#00838f", 0.16)}`,
bgcolor: alpha("#f8fbfc", 0.82),
}}
>
<Stack
spacing={1}
role="button"
tabIndex={0}
onClick={() => {
if (canCollapse) {
setExpanded((value) => !value);
}
}}
onKeyDown={(event) => {
if (canCollapse && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
setExpanded((value) => !value);
}
}}
sx={{
px: 1.4,
py: 1.15,
cursor: canCollapse ? "pointer" : "default",
transition: "background-color 0.2s ease",
"&:hover": canCollapse ? { bgcolor: alpha("#00838f", 0.035) } : undefined,
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<Box
sx={{
width: 28,
height: 28,
borderRadius: 1.5,
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: "#00838f",
bgcolor: alpha("#00838f", 0.1),
border: `1px solid ${alpha("#00838f", 0.14)}`,
}}
>
<AssignmentTurnedInRounded sx={{ fontSize: 18 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Stack direction="row" alignItems="center" spacing={0.75}>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
</Typography>
<Chip
size="small"
label={running ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"}
sx={{
height: 20,
borderRadius: "10px",
fontSize: "0.66rem",
fontWeight: 800,
color: running ? "#0277bd" : isAborted ? "text.secondary" : "#00838f",
bgcolor: alpha(running ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08),
"& .MuiChip-label": { px: 0.75 },
}}
/>
</Stack>
<Typography variant="caption" color="text.secondary">
{statusSummary}{updatedAtLabel ? ` · ${updatedAtLabel} 更新` : ""}
</Typography>
</Box>
{canCollapse ? (
<IconButton
size="small"
aria-label={expanded ? "收起会话任务" : "展开会话任务"}
sx={{
width: 28,
height: 28,
color: "text.secondary",
bgcolor: alpha("#000", 0.035),
"&:hover": { bgcolor: alpha("#000", 0.07) },
}}
>
{expanded ? (
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
) : (
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
)}
</IconButton>
) : null}
</Stack>
<Box
sx={{
height: 6,
borderRadius: 999,
overflow: "hidden",
bgcolor: alpha("#00838f", 0.1),
}}
>
<Box
sx={{
width: `${progress}%`,
height: "100%",
borderRadius: 999,
bgcolor: isAborted ? theme.palette.text.disabled : "#00838f",
transition: "width 0.25s ease",
}}
/>
</Box>
</Stack>
<Stack spacing={0} sx={{ px: 1.4, pb: 1.1 }}>
{pinnedTodos.map((todo, index) => renderTodoRow(todo, index))}
{canCollapse ? (
<Collapse in={expanded} timeout={220} unmountOnExit={false}>
<Stack spacing={0}>
{collapsibleTodos.map((todo, index) =>
renderTodoRow(todo, index + pinnedTodos.length),
)}
</Stack>
</Collapse>
) : null}
{hiddenCount > 0 ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
pt: 0.8,
borderTop: `1px solid ${alpha("#00838f", 0.08)}`,
}}
>
{hiddenCount}
</Typography>
) : null}
</Stack>
</Box>
);
};
+123 -301
View File
@@ -1,14 +1,11 @@
"use client";
import Image from "next/image";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import React, { useMemo } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Avatar,
Box,
Button,
IconButton,
Paper,
Stack,
@@ -18,82 +15,84 @@ import {
useTheme,
} from "@mui/material";
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
import RefreshRounded from "@mui/icons-material/RefreshRounded";
import EditRounded from "@mui/icons-material/EditRounded";
import CloseRounded from "@mui/icons-material/CloseRounded";
import ChevronLeftRounded from "@mui/icons-material/ChevronLeftRounded";
import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded";
import { TbArrowsSplit2 } from "react-icons/tb";
import type { PermissionReply } from "@/lib/chatStream";
import {
parseAssistantMessageSections,
parseContentWithToolCalls,
type ContentSegment,
} from "./chatMessageSections";
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
import type { BranchState, Message, SpeechState } from "./GlobalChatbox.types";
import type { Message, SpeechState } from "./GlobalChatbox.types";
import { stripMarkdown } from "./GlobalChatbox.utils";
import { AgentProgressTimeline } from "./AgentProgressTimeline";
import { ChatInlineChart } from "./ChatInlineChart";
import type { ChatChartSeries } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
import { AgentArtifactPanel } from "./AgentArtifactPanel";
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
import { PermissionRequestGroup } from "./AgentPermissionRequests";
import { QuestionRequestGroup } from "./AgentQuestionRequests";
import { TodoPlanCard } from "./AgentTodoPlanCard";
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
import StopRounded from "@mui/icons-material/StopRounded";
import SendRounded from "@mui/icons-material/SendRounded";
type AgentTurnProps = {
message: Message;
branchState?: BranchState;
isStreaming: boolean;
messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPause: () => void;
onResume: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
onRegenerate: () => void;
onEditResubmit: (messageId: string, newContent: string) => void;
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void;
};
const MarkdownBlock = ({ children }: { children: string }) => (
<div className={markdownStyles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
</div>
);
export const AgentTurn = React.memo(
({
message,
branchState,
isStreaming,
messageSpeechState,
onSpeak,
onPause,
onResume,
onStopSpeech,
isTtsSupported,
onRegenerate,
onEditResubmit,
onCycleBranch,
onCreateBranch,
onReplyPermission,
onReplyQuestion,
onRejectQuestion,
}: AgentTurnProps) => {
const theme = useTheme();
const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError);
const [isHovered, setIsHovered] = React.useState(false);
const [isEditing, setIsEditing] = React.useState(false);
const [editDraft, setEditDraft] = React.useState(message.content);
const rootMessageId = message.branchRootId ?? message.id;
const isProgressComplete = message.progress?.some(
(item) => item.phase === "complete" && item.status === "completed",
) ?? false;
const isProgressRunning = !isErrorMessage && !isProgressComplete && (
message.progress?.some((item) => item.status === "running") ?? false
);
const parsedAssistantSections =
!isUser && !isErrorMessage
? parseAssistantMessageSections(message.content)
: null;
const parsedAssistantSections = useMemo(
() =>
!isUser && !isErrorMessage
? parseAssistantMessageSections(message.content)
: null,
[isErrorMessage, isUser, message.content],
);
const answerContent = parsedAssistantSections?.answer ?? message.content;
const contentSegments: ContentSegment[] =
!isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }];
const contentSegments: ContentSegment[] = useMemo(
() =>
!isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }],
[answerContent, isErrorMessage, isUser],
);
if (isUser) {
return (
@@ -106,185 +105,33 @@ export const AgentTurn = React.memo(
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{isEditing ? (
<Paper
elevation={12}
sx={{
p: 1.5,
borderRadius: 5,
bgcolor: alpha("#ffffff", 0.75),
backdropFilter: "blur(40px)",
border: `1px solid ${alpha("#ffffff", 0.9)}`,
boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
minWidth: { xs: 260, sm: 320, md: 400 },
maxWidth: "100%",
}}
>
<Box component="textarea"
autoFocus
value={editDraft}
onChange={(e) => setEditDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (editDraft.trim() !== message.content) {
onEditResubmit(message.id, editDraft);
}
setIsEditing(false);
} else if (e.key === "Escape") {
setEditDraft(message.content);
setIsEditing(false);
}
}}
sx={{
width: "100%",
minHeight: 60,
bgcolor: "transparent",
border: "none",
outline: "none",
resize: "none",
fontFamily: "inherit",
fontSize: "1rem",
color: "text.primary",
lineHeight: 1.6,
}}
/>
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 1 }}>
<IconButton
size="small"
aria-label="取消"
onClick={() => { setEditDraft(message.content); setIsEditing(false); }}
sx={{
bgcolor: alpha("#000", 0.05),
color: "text.secondary",
width: 34, height: 34,
"&:hover": { bgcolor: alpha("#000", 0.1) }
}}
>
<CloseRounded fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label="发送修改"
disabled={editDraft.trim() === "" || editDraft.trim() === message.content}
onClick={() => {
onEditResubmit(message.id, editDraft);
setIsEditing(false);
}}
sx={{
bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00acc1" : alpha("#000", 0.1),
color: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#fff" : "action.disabled",
width: 34, height: 34,
boxShadow: editDraft.trim() !== "" && editDraft.trim() !== message.content ? `0 4px 12px ${alpha("#00acc1", 0.4)}` : "none",
"&:hover": { bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00838f" : alpha("#000", 0.1) }
}}
>
<SendRounded fontSize="small" sx={{ ml: 0.2 }} />
</IconButton>
</Stack>
</Paper>
) : (
<>
<Paper
elevation={4}
sx={{
p: 2,
borderRadius: 5,
borderBottomRightRadius: 2,
color: "#fff",
background: `linear-gradient(135deg, #0288d1, #00acc1)`,
boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`,
backdropFilter: "blur(10px)",
"--chat-md-text": alpha("#fff", 0.96),
"--chat-md-heading": "#fff",
"--chat-md-link": "#e0f7fa",
"--chat-md-link-hover": "#fff",
"--chat-md-inline-code-bg": "rgba(255,255,255,0.15)",
"--chat-md-inline-code-border": alpha("#fff", 0.1),
"--chat-md-inline-code-text": "#fff",
"--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)",
"--chat-md-pre-border": alpha("#fff", 0.1),
"--chat-md-pre-text": "#F8FAFC",
"--chat-md-quote-border": alpha("#fff", 0.4),
"--chat-md-quote-bg": alpha("#fff", 0.05),
"--chat-md-quote-text": alpha("#fff", 0.8),
}}
>
<MarkdownBlock>{message.content}</MarkdownBlock>
<AnimatePresence>
{isHovered && !isEditing && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
style={{ position: "absolute", top: -12, right: -8, zIndex: 10 }}
>
<IconButton
size="small"
onClick={() => { setIsEditing(true); setEditDraft(message.content); }}
aria-label="编辑提问"
sx={{
width: 26,
height: 26,
bgcolor: alpha("#fff", 0.9),
color: "#00acc1",
boxShadow: `0 2px 8px ${alpha("#000", 0.15)}`,
"&:hover": { bgcolor: "#fff", color: "#00838f" }
}}
>
<EditRounded sx={{ fontSize: 14 }} />
</IconButton>
</motion.div>
)}
</AnimatePresence>
</Paper>
{branchState && branchState.total > 1 ? (
<Stack
direction="row"
justifyContent="flex-end"
sx={{ mt: 0.5, mr: 0.5 }}
>
<Paper
elevation={0}
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 0.5,
py: 0.25,
borderRadius: 4,
bgcolor: alpha("#000", 0.04),
backdropFilter: "blur(4px)",
border: `1px solid ${alpha("#000", 0.08)}`,
}}
>
<IconButton
size="small"
aria-label="上一分支"
onClick={() => onCycleBranch(rootMessageId, -1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
<ChevronLeftRounded sx={{ fontSize: 16 }} />
</IconButton>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}>
{branchState.activeIndex + 1} / {branchState.total}
</Typography>
<IconButton
size="small"
aria-label="下一分支"
onClick={() => onCycleBranch(rootMessageId, 1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
<ChevronRightRounded sx={{ fontSize: 16 }} />
</IconButton>
</Paper>
</Stack>
) : null}
</>
)}
<Paper
elevation={4}
sx={{
p: 2,
borderRadius: 5,
borderBottomRightRadius: 2,
color: "#fff",
background: `linear-gradient(135deg, #0288d1, #00acc1)`,
boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`,
backdropFilter: "blur(10px)",
"--chat-md-text": alpha("#fff", 0.96),
"--chat-md-heading": "#fff",
"--chat-md-link": "#e0f7fa",
"--chat-md-link-hover": "#fff",
"--chat-md-inline-code-bg": "rgba(255,255,255,0.15)",
"--chat-md-inline-code-border": alpha("#fff", 0.1),
"--chat-md-inline-code-text": "#fff",
"--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)",
"--chat-md-pre-border": alpha("#fff", 0.1),
"--chat-md-pre-text": "#F8FAFC",
"--chat-md-quote-border": alpha("#fff", 0.4),
"--chat-md-quote-bg": alpha("#fff", 0.05),
"--chat-md-quote-text": alpha("#fff", 0.8),
}}
>
<MarkdownBlock>{message.content}</MarkdownBlock>
</Paper>
</motion.div>
);
}
@@ -353,6 +200,26 @@ export const AgentTurn = React.memo(
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
) : null}
{message.permissions?.length ? (
<PermissionRequestGroup
permissions={message.permissions}
isRunning={isProgressRunning}
onReply={onReplyPermission}
/>
) : null}
{message.questions?.length ? (
<QuestionRequestGroup
questions={message.questions}
onReply={onReplyQuestion}
onReject={onRejectQuestion}
/>
) : null}
{message.todos ? (
<TodoPlanCard todoUpdate={message.todos} />
) : null}
<Box
sx={{
p: 1.5,
@@ -412,7 +279,7 @@ export const AgentTurn = React.memo(
</Stack>
<AnimatePresence>
{isHovered && (
{isHovered && !isStreaming && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
@@ -438,7 +305,9 @@ export const AgentTurn = React.memo(
size="small"
aria-label="复制"
onClick={() => {
navigator.clipboard.writeText(message.content);
navigator.clipboard.writeText(
normalizeClipboardText(message.content),
);
// Could add a toast here
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
@@ -446,16 +315,16 @@ export const AgentTurn = React.memo(
<ContentCopyRounded sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title="重新生成">
<IconButton
size="small"
aria-label="重新生成"
<Tooltip title="拆分为新会话">
<IconButton
size="small"
aria-label="拆分为新会话"
onClick={() => {
onRegenerate();
onCreateBranch(message.id);
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
>
<RefreshRounded sx={{ fontSize: 16 }} />
<TbArrowsSplit2 size={16} />
</IconButton>
</Tooltip>
</Paper>
@@ -466,87 +335,40 @@ export const AgentTurn = React.memo(
</Paper>
</Stack>
{(!isErrorMessage && isTtsSupported) || (branchState && branchState.total > 1) ? (
{!isErrorMessage && isTtsSupported ? (
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.5, ml: 6, mb: 1 }}>
<Stack direction="row" spacing={0.5} sx={{ opacity: isHovered ? 1 : 0.4, transition: "opacity 0.2s" }}>
{!isErrorMessage && isTtsSupported ? (
{messageSpeechState === "idle" ? (
<IconButton
size="small"
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
aria-label="朗读消息"
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
>
<VolumeUpRounded sx={{ fontSize: 16 }} />
</IconButton>
) : null}
{messageSpeechState === "playing" ? (
<>
{messageSpeechState === "idle" ? (
<IconButton
size="small"
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
aria-label="朗读消息"
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
>
<VolumeUpRounded sx={{ fontSize: 16 }} />
</IconButton>
) : null}
{messageSpeechState === "playing" ? (
<>
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
<PauseRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
) : null}
{messageSpeechState === "paused" ? (
<>
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
<PlayArrowRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
) : null}
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
<PauseRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
) : null}
{messageSpeechState === "paused" ? (
<>
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
<PlayArrowRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
) : null}
</Stack>
{branchState && branchState.total > 1 ? (
<Stack
direction="row"
justifyContent="flex-start"
sx={{ mr: 0.5 }}
>
<Paper
elevation={0}
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 0.5,
py: 0.25,
borderRadius: 4,
bgcolor: alpha("#000", 0.04),
backdropFilter: "blur(4px)",
border: `1px solid ${alpha("#000", 0.08)}`,
}}
>
<IconButton
size="small"
aria-label="上一分支"
onClick={() => onCycleBranch(rootMessageId, -1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
<ChevronLeftRounded sx={{ fontSize: 16 }} />
</IconButton>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}>
{branchState.activeIndex + 1} / {branchState.total}
</Typography>
<IconButton
size="small"
aria-label="下一分支"
onClick={() => onCycleBranch(rootMessageId, 1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
<ChevronRightRounded sx={{ fontSize: 16 }} />
</IconButton>
</Paper>
</Stack>
) : null}
</Stack>
) : null}
</motion.div>
@@ -0,0 +1,96 @@
/* eslint-disable @next/next/no-img-element */
import "@testing-library/jest-dom";
import React from "react";
import { render } from "@testing-library/react";
import { AgentWorkspace } from "./AgentWorkspace";
import type { Message } from "./GlobalChatbox.types";
const renderCounts = new Map<string, number>();
jest.mock("next/image", () => ({
__esModule: true,
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt ?? ""} />,
}));
jest.mock("framer-motion", () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
motion: {
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
},
}));
jest.mock("./GlobalChatbox.parts", () => ({
TypingIndicator: () => <div>typing</div>,
}));
jest.mock("./AgentTurn", () => ({
AgentTurn: ({ message }: { message: Message }) => {
renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1);
return <div data-testid={`turn-${message.id}`}>{message.content}</div>;
},
}));
describe("AgentWorkspace", () => {
const defaultProps = {
bottomRef: { current: null },
speakingMessageId: null,
speechState: "idle" as const,
onSpeak: jest.fn(),
onPauseSpeech: jest.fn(),
onResumeSpeech: jest.fn(),
onStopSpeech: jest.fn(),
isTtsSupported: false,
onCreateBranch: jest.fn(),
onReplyPermission: jest.fn(),
onReplyQuestion: jest.fn(),
onRejectQuestion: jest.fn(),
};
beforeEach(() => {
renderCounts.clear();
});
it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
const userMessage: Message = {
id: "user-1",
role: "user",
content: "question",
};
const assistantHistoryMessage: Message = {
id: "assistant-1",
role: "assistant",
content: "stable answer",
};
const streamingMessage: Message = {
id: "assistant-2",
role: "assistant",
content: "partial",
};
const { rerender } = render(
<AgentWorkspace
{...defaultProps}
isStreaming
messages={[userMessage, assistantHistoryMessage, streamingMessage]}
/>,
);
const updatedStreamingMessage: Message = {
...streamingMessage,
content: "partial with more tokens",
};
rerender(
<AgentWorkspace
{...defaultProps}
isStreaming
messages={[userMessage, assistantHistoryMessage, updatedStreamingMessage]}
/>,
);
expect(renderCounts.get("user-1")).toBe(1);
expect(renderCounts.get("assistant-1")).toBe(1);
expect(renderCounts.get("assistant-2")).toBe(2);
});
});
+124 -63
View File
@@ -11,17 +11,14 @@ import MapRounded from "@mui/icons-material/MapRounded";
import { AgentTurn } from "./AgentTurn";
import { TypingIndicator } from "./GlobalChatbox.parts";
import type { PermissionReply } from "@/lib/chatStream";
import type {
BranchGroup,
BranchTransition,
Message,
SpeechState,
} from "./GlobalChatbox.types";
type AgentWorkspaceProps = {
messages: Message[];
branchGroups: BranchGroup[];
branchTransition: BranchTransition | null;
isStreaming: boolean;
bottomRef: React.RefObject<HTMLDivElement | null>;
speakingMessageId: string | null;
@@ -31,11 +28,90 @@ type AgentWorkspaceProps = {
onResumeSpeech: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
onRegenerate: () => void;
onEditResubmit: (messageId: string, newContent: string) => void;
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void;
};
type TurnListProps = {
messages: Message[];
isStreaming: boolean;
speakingMessageId: string | null;
speechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPauseSpeech: () => void;
onResumeSpeech: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void;
};
const sameMessages = (left: Message[], right: Message[]) =>
left.length === right.length &&
left.every((message, index) => message === right[index]);
const TurnListInner = ({
messages,
isStreaming,
speakingMessageId,
speechState,
onSpeak,
onPauseSpeech,
onResumeSpeech,
onStopSpeech,
isTtsSupported,
onCreateBranch,
onReplyPermission,
onReplyQuestion,
onRejectQuestion,
}: TurnListProps) => {
return (
<>
{messages.map((message) => (
<AgentTurn
key={message.id}
message={message}
isStreaming={isStreaming}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={onSpeak}
onPause={onPauseSpeech}
onResume={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onCreateBranch={onCreateBranch}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
))}
</>
);
};
const TurnList = React.memo(
TurnListInner,
(prevProps, nextProps) =>
sameMessages(prevProps.messages, nextProps.messages) &&
prevProps.isStreaming === nextProps.isStreaming &&
prevProps.speakingMessageId === nextProps.speakingMessageId &&
prevProps.speechState === nextProps.speechState &&
prevProps.onSpeak === nextProps.onSpeak &&
prevProps.onPauseSpeech === nextProps.onPauseSpeech &&
prevProps.onResumeSpeech === nextProps.onResumeSpeech &&
prevProps.onStopSpeech === nextProps.onStopSpeech &&
prevProps.isTtsSupported === nextProps.isTtsSupported &&
prevProps.onCreateBranch === nextProps.onCreateBranch &&
prevProps.onReplyPermission === nextProps.onReplyPermission &&
prevProps.onReplyQuestion === nextProps.onReplyQuestion &&
prevProps.onRejectQuestion === nextProps.onRejectQuestion,
);
TurnList.displayName = "TurnList";
const EmptyState = () => {
const theme = useTheme();
const capabilities = [
@@ -152,8 +228,6 @@ const EmptyState = () => {
export const AgentWorkspace = ({
messages,
branchGroups,
branchTransition,
isStreaming,
bottomRef,
speakingMessageId,
@@ -163,9 +237,10 @@ export const AgentWorkspace = ({
onResumeSpeech,
onStopSpeech,
isTtsSupported,
onRegenerate,
onEditResubmit,
onCycleBranch,
onCreateBranch,
onReplyPermission,
onReplyQuestion,
onRejectQuestion,
}: AgentWorkspaceProps) => {
const theme = useTheme();
const latestAssistant = [...messages]
@@ -176,43 +251,12 @@ export const AgentWorkspace = ({
(!latestAssistant ||
(latestAssistant.content.trim().length === 0 &&
!(latestAssistant.artifacts?.length)));
const stableMessages = branchTransition
? messages.slice(0, branchTransition.parentCount)
: messages;
const transitionMessages = branchTransition
? messages.slice(branchTransition.parentCount)
: [];
const renderTurn = (message: Message) => {
const rootMessageId = message.branchRootId ?? message.id;
const branchGroup = branchGroups.find(
(group) => group.rootMessageId === rootMessageId,
);
return (
<AgentTurn
key={rootMessageId}
message={message}
branchState={
branchGroup && branchGroup.branches.length > 1
? {
activeIndex: branchGroup.activeIndex,
total: branchGroup.branches.length,
}
: undefined
}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={onSpeak}
onPause={onPauseSpeech}
onResume={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onRegenerate={onRegenerate}
onEditResubmit={onEditResubmit}
onCycleBranch={onCycleBranch}
/>
);
};
const streamingMessage =
isStreaming && messages.at(-1)?.role === "assistant"
? messages.at(-1)
: undefined;
const historyMessages =
streamingMessage !== undefined ? messages.slice(0, -1) : messages;
return (
<Box
@@ -232,21 +276,38 @@ export const AgentWorkspace = ({
{messages.length > 0 ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{stableMessages.map(renderTurn)}
<TurnList
messages={historyMessages}
isStreaming={isStreaming}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={onSpeak}
onPauseSpeech={onPauseSpeech}
onResumeSpeech={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onCreateBranch={onCreateBranch}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
{branchTransition ? (
<AnimatePresence initial={false} mode="wait">
<motion.div
key={`${branchTransition.rootMessageId}:${branchTransition.activeBranchId}:${branchTransition.nonce}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
{transitionMessages.map(renderTurn)}
</motion.div>
</AnimatePresence>
{streamingMessage ? (
<TurnList
messages={[streamingMessage]}
isStreaming={isStreaming}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={onSpeak}
onPauseSpeech={onPauseSpeech}
onResumeSpeech={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onCreateBranch={onCreateBranch}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
) : null}
</Box>
) : null}
+27
View File
@@ -25,6 +25,11 @@ import {
type ChatToolAction,
} from "@/store/chatToolStore";
import type { ToolCall } from "./chatMessageSections";
import {
APPLY_LAYER_STYLE_TOOL,
describeApplyLayerStyle,
parseApplyLayerStylePayload,
} from "./toolCallStyleHelpers";
/* ------------------------------------------------------------------ */
/* Interactive card rendered inside a chat bubble for tool actions */
@@ -137,6 +142,12 @@ const TOOL_META: Record<string, ToolMeta> = {
actionLabel: "应用渲染",
color: "#3b82f6",
},
[APPLY_LAYER_STYLE_TOOL]: {
label: "图层样式",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "应用样式",
color: "#14b8a6",
},
};
/* ---------- helpers ---------- */
@@ -270,6 +281,10 @@ function getToolDescription(toolCall: ToolCall): string {
case "render_junctions": {
return (params.render_ref as string | undefined) ?? "渲染引用";
}
case APPLY_LAYER_STYLE_TOOL: {
const payload = parseApplyLayerStylePayload(params);
return payload ? describeApplyLayerStyle(payload) : "图层样式";
}
default:
return "";
}
@@ -403,6 +418,18 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
renderRef,
};
}
case APPLY_LAYER_STYLE_TOOL: {
const payload = parseApplyLayerStylePayload(params);
if (!payload) {
return null;
}
return {
type: "apply_layer_style",
layerId: payload.layerId,
resetToDefault: payload.resetToDefault,
styleConfig: payload.styleConfig,
};
}
default:
return null;
}
+92 -56
View File
@@ -7,9 +7,12 @@ import React, {
useState,
} from "react";
import { Box, Drawer, alpha, useTheme } from "@mui/material";
import { useNotification } from "@refinedev/core";
import type { AgentModel } from "@/lib/chatStream";
import { AgentComposer } from "./AgentComposer";
import { getAccessToken } from "@/lib/authToken";
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
import { useProjectStore } from "@/store/projectStore";
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
import { AgentHeader } from "./AgentHeader";
import { AgentHistoryPanel } from "./AgentHistoryPanel";
import { AgentWorkspace } from "./AgentWorkspace";
@@ -21,17 +24,22 @@ import { useAgentChatSession } from "./hooks/useAgentChatSession";
import { useAgentToolActions } from "./hooks/useAgentToolActions";
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [input, setInput] = useState("");
const [width, setWidth] = useState(520);
const [isResizing, setIsResizing] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
const [selectedModel, setSelectedModel] = useState<AgentModel>(
"deepseek/deepseek-v4-pro",
);
const [approvalMode, setApprovalMode] =
useState<AgentApprovalMode>("request");
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const composerRef = useRef<AgentComposerHandle | null>(null);
const hasResetForOpenRef = useRef(false);
const theme = useTheme();
const { open: openNotification } = useNotification();
const currentProjectId = useProjectStore((state) => state.currentProjectId);
const {
speechState,
@@ -44,7 +52,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
} = useSpeechSynthesis();
const handleSpeechResult = useCallback((text: string) => {
setInput((prev) => prev + text);
composerRef.current?.append(text);
}, []);
const {
@@ -58,96 +66,126 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const {
messages,
chatSessions,
activeStorageSessionId,
branchGroups,
branchTransition,
activeSessionId,
isHydrating,
isStreaming,
sessionTitle,
sendPrompt,
regenerate,
editAndResubmit,
cycleBranch,
createBranch,
abort,
replyPermission,
replyQuestion,
rejectQuestion,
createSession,
renameSession,
removeSession,
switchSession,
} = useAgentChatSession({
projectId: currentProjectId,
onToolCall: handleToolCall,
onBeforeSend: stopListening,
getModel: () => selectedModel,
getApprovalMode: () => approvalMode,
});
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isStreaming]);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
bottomRef.current?.scrollIntoView({ behavior });
}, []);
useEffect(() => {
if (!open) return;
scrollToBottom(isStreaming ? "auto" : "smooth");
}, [isStreaming, messages, scrollToBottom]);
useEffect(() => {
if (!open) {
hasResetForOpenRef.current = false;
return;
}
if (hasResetForOpenRef.current || isHydrating) return;
hasResetForOpenRef.current = true;
const timer = window.setTimeout(() => {
inputRef.current?.focus();
bottomRef.current?.scrollIntoView({ behavior: "auto" });
createSession();
composerRef.current?.clear();
setIsHistoryOpen(false);
composerRef.current?.focus();
scrollToBottom("auto");
}, 0);
return () => window.clearTimeout(timer);
}, [open]);
}, [createSession, isHydrating, open, scrollToBottom]);
const handleSend = useCallback(() => {
const prompt = input.trim();
if (!prompt || isStreaming) return;
setInput("");
void sendPrompt(prompt);
}, [input, isStreaming, sendPrompt]);
const handleSend = useCallback(async (prompt: string) => {
if (isStreaming || isCheckingAuth) return;
const handlePresetPromptSelect = useCallback((prompt: string) => {
setInput(prompt);
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}, []);
setIsCheckingAuth(true);
try {
const accessToken = await getAccessToken();
if (!accessToken) {
composerRef.current?.setValue(prompt);
openNotification?.({
type: "error",
message: "登录状态已失效",
description: "请重新登录后再发送对话。",
});
return;
}
void sendPrompt(prompt);
} catch (error) {
composerRef.current?.setValue(prompt);
openNotification?.({
type: "error",
message: "登录状态校验失败",
description: error instanceof Error ? error.message : "请重新登录后再试。",
});
} finally {
setIsCheckingAuth(false);
}
}, [isCheckingAuth, isStreaming, openNotification, sendPrompt]);
const handleNewConversation = useCallback(() => {
handleStopSpeech();
stopListening();
void createSession();
setInput("");
createSession();
composerRef.current?.clear();
window.setTimeout(() => {
inputRef.current?.focus();
composerRef.current?.focus();
scrollToBottom("auto");
}, 0);
}, [createSession, handleStopSpeech, stopListening]);
}, [createSession, handleStopSpeech, scrollToBottom, stopListening]);
const handleHistoryToggle = useCallback(() => {
setIsHistoryOpen((prev) => !prev);
}, []);
const handleSelectSession = useCallback(
(storageSessionId: string) => {
setInput("");
void switchSession(storageSessionId);
(sessionId: string) => {
composerRef.current?.clear();
void switchSession(sessionId);
},
[switchSession],
);
const handleDeleteSession = useCallback(
(storageSessionId: string) => {
void removeSession(storageSessionId);
(sessionId: string) => {
void removeSession(sessionId);
},
[removeSession],
);
const handleRenameSession = useCallback(
(storageSessionId: string, title: string) => {
void renameSession(storageSessionId, title);
(sessionId: string, title: string) => {
void renameSession(sessionId, title);
},
[renameSession],
);
const handleRenameActiveSession = useCallback(
(title: string) => {
if (!activeStorageSessionId) return;
void renameSession(activeStorageSessionId, title);
if (!activeSessionId) return;
void renameSession(activeSessionId, title);
},
[activeStorageSessionId, renameSession],
[activeSessionId, renameSession],
);
const handleMouseDown = useCallback((event: React.MouseEvent) => {
@@ -247,7 +285,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<AgentHeader
sessionTitle={sessionTitle}
canRenameSessionTitle={Boolean(activeStorageSessionId)}
canRenameSessionTitle={Boolean(activeSessionId)}
isHydrating={isHydrating}
isStreaming={isStreaming}
isHistoryOpen={isHistoryOpen}
@@ -286,7 +324,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
>
<AgentHistoryPanel
sessions={chatSessions}
activeSessionId={activeStorageSessionId}
activeSessionId={activeSessionId}
isHydrating={isHydrating}
onNewSession={() => {
handleNewConversation();
@@ -304,8 +342,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<Box sx={{ flex: 1, display: "flex", minWidth: 0, flexDirection: "column" }}>
<AgentWorkspace
messages={messages}
branchGroups={branchGroups}
branchTransition={branchTransition}
isStreaming={isStreaming}
bottomRef={bottomRef}
speakingMessageId={speakingMessageId}
@@ -315,27 +351,27 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
onResumeSpeech={handleResumeSpeech}
onStopSpeech={handleStopSpeech}
isTtsSupported={isTtsSupported}
onRegenerate={regenerate}
onEditResubmit={editAndResubmit}
onCycleBranch={cycleBranch}
onCreateBranch={createBranch}
onReplyPermission={replyPermission}
onReplyQuestion={replyQuestion}
onRejectQuestion={rejectQuestion}
/>
<AgentComposer
input={input}
inputRef={inputRef}
isHydrating={isHydrating}
ref={composerRef}
isHydrating={isHydrating || isCheckingAuth}
isStreaming={isStreaming}
isListening={isListening}
isSttSupported={isSttSupported}
presets={PRESET_PROMPTS}
onInputChange={setInput}
onSend={handleSend}
onAbort={abort}
onStartListening={startListening}
onStopListening={stopListening}
onPresetSelect={handlePresetPromptSelect}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
approvalMode={approvalMode}
onApprovalModeChange={setApprovalMode}
/>
</Box>
</Box>
+39 -54
View File
@@ -1,3 +1,8 @@
import type {
AgentQuestionRequest,
AgentTodoUpdate,
} from "@/lib/chatStream";
export type ChatProgress = {
id: string;
phase: string;
@@ -22,6 +27,32 @@ export type AgentArtifact = {
params: Record<string, unknown>;
};
export type AgentPermissionStatus =
| "pending"
| "submitting"
| "approved_once"
| "approved_always"
| "rejected"
| "aborted"
| "error";
export type AgentPermissionRequest = {
requestId: string;
sessionId: string;
permission: string;
patterns: string[];
target?: string;
always: string[];
tool?: {
messageID: string;
callID: string;
};
createdAt: number;
repliedAt?: number;
status: AgentPermissionStatus;
error?: string;
};
export type Message = {
id: string;
role: "user" | "assistant";
@@ -29,34 +60,9 @@ export type Message = {
isError?: boolean;
progress?: ChatProgress[];
artifacts?: AgentArtifact[];
branchRootId?: string;
};
export type BranchState = {
activeIndex: number;
total: number;
};
export type MessageBranch = {
id: string;
label: string;
sessionId?: string;
messages: Message[];
};
export type BranchGroup = {
id: string;
rootMessageId: string;
parentCount: number;
activeIndex: number;
branches: MessageBranch[];
};
export type BranchTransition = {
rootMessageId: string;
parentCount: number;
activeBranchId: string;
nonce: number;
permissions?: AgentPermissionRequest[];
questions?: AgentQuestionRequest[];
todos?: AgentTodoUpdate;
};
export type Props = {
@@ -66,41 +72,20 @@ export type Props = {
export type SpeechState = "idle" | "playing" | "paused";
export type LegacyPersistedChatState = {
messages: Message[];
sessionId?: string;
branchGroups?: BranchGroup[];
};
export type ChatSessionRecord = {
id: string;
title: string;
isTitleManuallyEdited?: boolean;
createdAt: number;
updatedAt: number;
sessionId?: string;
messages: Message[];
branchGroups: BranchGroup[];
};
export type ChatSessionSummary = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
};
export type ChatStorageMeta = {
key: "chat-meta";
activeSessionId?: string;
migratedFromLocalStorage?: boolean;
isStreaming?: boolean;
runStatus?: string;
};
export type LoadedChatState = {
storageSessionId?: string;
sessionId?: string;
title?: string;
isTitleManuallyEdited?: boolean;
messages: Message[];
sessionId?: string;
branchGroups: BranchGroup[];
isStreaming?: boolean;
runStatus?: string;
};
@@ -0,0 +1,35 @@
import { cloneMessage } from "./GlobalChatbox.utils";
import type { Message } from "./GlobalChatbox.types";
describe("cloneMessage", () => {
it("normalizes persisted question and todo arrays", () => {
const message = {
id: "assistant-1",
role: "assistant",
content: "需要补充信息",
questions: [
{
requestId: "question-1",
sessionId: "session-1",
questions: [
{
header: "范围",
question: "请选择分析范围",
},
],
createdAt: 1,
status: "pending",
},
],
todos: {
sessionId: "session-1",
createdAt: 1,
},
} as unknown as Message;
const cloned = cloneMessage(message);
expect(cloned.questions?.[0]?.questions[0]?.options).toEqual([]);
expect(cloned.todos?.todos).toEqual([]);
});
});
+62 -12
View File
@@ -1,4 +1,8 @@
import type { BranchGroup, Message } from "./GlobalChatbox.types";
import type { Message } from "./GlobalChatbox.types";
import type {
AgentQuestionRequest,
AgentTodoUpdate,
} from "@/lib/chatStream";
export const createId = () =>
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -29,19 +33,65 @@ export const stripMarkdown = (md: string): string =>
.replace(/<[^>]+>/g, "")
.trim();
const normalizeQuestionRequests = (
questions: Message["questions"],
): Message["questions"] =>
Array.isArray(questions)
? questions.map((request) => ({
...request,
questions: Array.isArray(request.questions)
? request.questions.map((question) => ({
...question,
header: typeof question.header === "string" ? question.header : "",
question:
typeof question.question === "string" ? question.question : "",
options: Array.isArray(question.options)
? question.options.map((option) => ({
label:
typeof option.label === "string" ? option.label : "",
description:
typeof option.description === "string"
? option.description
: "",
}))
: [],
}))
: [],
answers: Array.isArray(request.answers)
? request.answers.map((answer) =>
Array.isArray(answer)
? answer.filter((item): item is string => typeof item === "string")
: [],
)
: undefined,
} satisfies AgentQuestionRequest))
: undefined;
const normalizeTodoUpdate = (todos: Message["todos"]): Message["todos"] => {
if (!todos) return undefined;
return {
...todos,
todos: Array.isArray(todos.todos)
? todos.todos.map((todo) => ({ ...todo }))
: [],
} satisfies AgentTodoUpdate;
};
export const cloneMessage = (message: Message): Message => ({
...message,
progress: message.progress ? [...message.progress] : undefined,
artifacts: message.artifacts ? [...message.artifacts] : undefined,
progress: Array.isArray(message.progress) ? [...message.progress] : undefined,
artifacts: Array.isArray(message.artifacts) ? [...message.artifacts] : undefined,
permissions: Array.isArray(message.permissions)
? message.permissions.map((permission) => ({
...permission,
patterns: Array.isArray(permission.patterns)
? [...permission.patterns]
: [],
always: Array.isArray(permission.always) ? [...permission.always] : [],
}))
: undefined,
questions: normalizeQuestionRequests(message.questions),
todos: normalizeTodoUpdate(message.todos),
});
export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage);
export const cloneBranchGroups = (branchGroups: BranchGroup[]) =>
branchGroups.map((group) => ({
...group,
branches: group.branches.map((branch) => ({
...branch,
messages: cloneMessages(branch.messages),
})),
}));
+51 -123
View File
@@ -1,142 +1,70 @@
import type { ChatSessionRecord } from "./GlobalChatbox.types";
import {
createEmptyChatSession,
loadChatSessionById,
createEmptyChatState,
saveActiveChatState,
updateChatSessionTitle,
} from "./chatStorage";
type StoreName = "sessions" | "meta";
const apiFetch = jest.fn();
const stores: Record<StoreName, Map<string, any>> = {
sessions: new Map(),
meta: new Map(),
};
const mockDb = {
get: jest.fn(async (storeName: StoreName, key: string) => stores[storeName].get(key)),
getAll: jest.fn(async (storeName: StoreName) => Array.from(stores[storeName].values())),
put: jest.fn(async (storeName: StoreName, value: { id?: string; key?: string }) => {
const key = storeName === "sessions" ? value.id : value.key;
if (!key) {
throw new Error(`Missing key for store ${storeName}`);
}
stores[storeName].set(key, value);
return key;
}),
delete: jest.fn(async (storeName: StoreName, key: string) => {
stores[storeName].delete(key);
}),
};
jest.mock("idb", () => ({
openDB: jest.fn(async () => mockDb),
jest.mock("@/lib/apiFetch", () => ({
apiFetch: (...args: unknown[]) => apiFetch(...args),
}));
describe("chatStorage timestamp semantics", () => {
let now = new Date("2026-05-19T09:00:00+08:00").getTime();
let dateNowSpy: jest.SpyInstance<number, []>;
describe("chatStorage backend-only persistence", () => {
beforeEach(() => {
stores.sessions.clear();
stores.meta.clear();
mockDb.get.mockClear();
mockDb.getAll.mockClear();
mockDb.put.mockClear();
mockDb.delete.mockClear();
window.localStorage.clear();
now = new Date("2026-05-19T09:00:00+08:00").getTime();
dateNowSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
apiFetch.mockReset();
});
afterEach(() => {
dateNowSpy.mockRestore();
it("creates an empty initial conversation state without backend calls", () => {
const loaded = createEmptyChatState();
expect(loaded).toMatchObject({
title: undefined,
messages: [],
sessionId: undefined,
});
expect(apiFetch).not.toHaveBeenCalled();
});
it("keeps anchor and content timestamps when reopening an old session", async () => {
const record: ChatSessionRecord = {
id: "old-session",
title: "很久之前的会话",
isTitleManuallyEdited: false,
createdAt: new Date("2026-04-01T10:00:00+08:00").getTime(),
updatedAt: new Date("2026-04-01T10:30:00+08:00").getTime(),
sessionId: "remote-1",
messages: [
{
id: "message-1",
role: "user",
content: "老问题",
branchRootId: "message-1",
},
],
branchGroups: [],
};
stores.sessions.set(record.id, record);
it("creates a backend conversation when saving the first non-empty state", async () => {
apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
if (url.endsWith("/api/v1/agent/chat/session")) {
expect(init?.method).toBe("POST");
return {
ok: true,
json: async () => ({ session_id: "chat-new-1" }),
} as Response;
}
const loadedState = await loadChatSessionById(record.id);
now = new Date("2026-05-19T09:30:00+08:00").getTime();
await saveActiveChatState(loadedState);
if (url.endsWith("/api/v1/agent/chat/session/chat-new-1")) {
expect(init?.method).toBe("PUT");
expect(JSON.parse(String(init?.body))).toMatchObject({
title: "新对话",
is_title_manually_edited: false,
});
return {
ok: true,
json: async () => ({ id: "chat-new-1", session_id: "chat-new-1" }),
} as Response;
}
expect(stores.sessions.get(record.id)).toMatchObject({
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
});
it("does not change timestamps when renaming a session", async () => {
const record: ChatSessionRecord = {
id: "rename-session",
title: "旧标题",
isTitleManuallyEdited: false,
createdAt: new Date("2026-04-10T08:00:00+08:00").getTime(),
updatedAt: new Date("2026-04-10T08:05:00+08:00").getTime(),
sessionId: "remote-2",
messages: [
{
id: "message-2",
role: "user",
content: "保留时间",
branchRootId: "message-2",
},
],
branchGroups: [],
};
stores.sessions.set(record.id, record);
now = new Date("2026-05-19T11:00:00+08:00").getTime();
await updateChatSessionTitle(record.id, "新标题", {
isTitleManuallyEdited: true,
throw new Error(`Unexpected request ${url}`);
});
expect(stores.sessions.get(record.id)).toMatchObject({
title: "新标题",
isTitleManuallyEdited: true,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
});
const savedSessionId = await saveActiveChatState(
{
title: "新对话",
isTitleManuallyEdited: false,
messages: [
{
id: "message-2",
role: "user",
content: "第一条消息",
},
],
sessionId: undefined,
},
);
it("anchors createdAt to the first real message time for a new empty session", async () => {
const emptyState = await createEmptyChatSession();
const storageSessionId = emptyState.storageSessionId;
now = new Date("2026-05-19T09:05:00+08:00").getTime();
await saveActiveChatState({
...emptyState,
messages: [
{
id: "message-3",
role: "user",
content: "第一条消息",
branchRootId: "message-3",
},
],
sessionId: "remote-3",
});
expect(stores.sessions.get(storageSessionId!)).toMatchObject({
createdAt: now,
updatedAt: now,
});
expect(savedSessionId).toBe("chat-new-1");
});
});
+186 -324
View File
@@ -1,79 +1,42 @@
import { openDB, type DBSchema } from "idb";
import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config";
import type {
BranchGroup,
ChatSessionRecord,
ChatSessionSummary,
ChatStorageMeta,
LegacyPersistedChatState,
LoadedChatState,
Message,
} from "./GlobalChatbox.types";
import {
cloneBranchGroups,
cloneMessages,
createId,
} from "./GlobalChatbox.utils";
import { cloneMessages } from "./GlobalChatbox.utils";
const CHAT_DB_NAME = "tjwater-agent-chat";
const CHAT_DB_VERSION = 1;
const SESSION_STORE = "sessions";
const META_STORE = "meta";
const META_KEY = "chat-meta" as const;
const LEGACY_CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1";
type ChatDB = DBSchema & {
sessions: {
key: string;
value: ChatSessionRecord;
indexes: {
"by-updatedAt": number;
};
};
meta: {
key: string;
value: ChatStorageMeta;
};
type BackendSessionPayload = {
id?: string;
title?: string;
created_at?: string | number;
updated_at?: string | number;
is_streaming?: boolean;
run_status?: string;
};
const emptyLoadedChatState = (): LoadedChatState => ({
storageSessionId: undefined,
export const createEmptyChatState = (): LoadedChatState => ({
title: undefined,
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
branchGroups: [],
});
const sanitizeMessages = (messages: Message[] | undefined) =>
Array.isArray(messages) ? cloneMessages(messages) : [];
const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : [];
const serializeConversationState = (state: {
messages: Message[];
branchGroups: BranchGroup[];
sessionId?: string;
}) =>
JSON.stringify({
messages: sanitizeMessages(state.messages),
branchGroups: sanitizeBranchGroups(state.branchGroups),
sessionId: state.sessionId ?? null,
});
const hasChatContent = (state: {
messages: Message[];
branchGroups: BranchGroup[];
sessionId?: string;
}) =>
state.messages.length > 0 ||
state.branchGroups.length > 0 ||
Boolean(state.sessionId);
const compareSessionsByAnchorTime = (
left: Pick<ChatSessionRecord, "id" | "createdAt" | "updatedAt">,
right: Pick<ChatSessionRecord, "id" | "createdAt" | "updatedAt">,
left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
right: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
) => {
const createdAtDiff = right.createdAt - left.createdAt;
if (createdAtDiff !== 0) return createdAtDiff;
@@ -84,240 +47,199 @@ const compareSessionsByAnchorTime = (
return right.id.localeCompare(left.id);
};
const toLoadedChatState = (
session: ChatSessionRecord | undefined,
): LoadedChatState => {
if (!session) return emptyLoadedChatState();
return {
storageSessionId: session.id,
title: session.title,
isTitleManuallyEdited: session.isTitleManuallyEdited ?? false,
messages: sanitizeMessages(session.messages),
sessionId: session.sessionId,
branchGroups: sanitizeBranchGroups(session.branchGroups),
const toMillis = (value: string | number | undefined) =>
typeof value === "number" ? value : value ? new Date(value).getTime() : Date.now();
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
method: "GET",
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
});
if (!response.ok) {
throw new Error(await response.text());
}
const payload = (await response.json()) as {
sessions?: BackendSessionPayload[];
};
return (payload.sessions ?? [])
.map((session) => ({
id: session.id ?? "",
title: normalizeTitle(session.title),
createdAt: toMillis(session.created_at),
updatedAt: toMillis(session.updated_at),
isStreaming: session.is_streaming,
runStatus: session.run_status,
}))
.filter((session) => Boolean(session.id))
.sort(compareSessionsByAnchorTime);
};
const toSessionSummary = (session: ChatSessionRecord): ChatSessionSummary => ({
id: session.id,
title: session.title,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
});
const getDb = () =>
openDB<ChatDB>(CHAT_DB_NAME, CHAT_DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(SESSION_STORE)) {
const sessionStore = db.createObjectStore(SESSION_STORE, {
keyPath: "id",
});
sessionStore.createIndex("by-updatedAt", "updatedAt");
}
if (!db.objectStoreNames.contains(META_STORE)) {
db.createObjectStore(META_STORE, { keyPath: "key" });
}
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
{
method: "GET",
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
});
const readLegacyChatState = (): LegacyPersistedChatState | null => {
if (typeof window === "undefined") return null;
try {
const storedRaw = window.localStorage.getItem(LEGACY_CHAT_STORAGE_KEY);
if (!storedRaw) return null;
const parsed = JSON.parse(storedRaw) as LegacyPersistedChatState;
if (!Array.isArray(parsed.messages)) {
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
return null;
);
if (!response.ok) {
if (response.status === 404) {
return createEmptyChatState();
}
return {
messages: sanitizeMessages(parsed.messages),
sessionId: parsed.sessionId,
branchGroups: sanitizeBranchGroups(parsed.branchGroups),
};
} catch (error) {
console.error("[GlobalChatbox] Failed to read legacy chat state:", error);
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
return null;
throw new Error(await response.text());
}
};
const clearLegacyChatState = () => {
if (typeof window === "undefined") return;
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
};
const getMeta = async () => {
const db = await getDb();
return db.get(META_STORE, META_KEY);
};
const setMeta = async (meta: Omit<ChatStorageMeta, "key">) => {
const db = await getDb();
await db.put(META_STORE, {
key: META_KEY,
...meta,
});
};
const getLatestSession = async () => {
const db = await getDb();
const sessions = await db.getAll(SESSION_STORE);
if (sessions.length === 0) return undefined;
return sessions.sort(compareSessionsByAnchorTime)[0];
};
const migrateLegacyLocalStorage = async () => {
const meta = await getMeta();
if (meta?.migratedFromLocalStorage) return;
const legacyState = readLegacyChatState();
if (!legacyState) {
await setMeta({
activeSessionId: meta?.activeSessionId,
migratedFromLocalStorage: true,
});
return;
}
const hasContent =
legacyState.messages.length > 0 ||
(legacyState.branchGroups?.length ?? 0) > 0 ||
Boolean(legacyState.sessionId);
if (!hasContent) {
clearLegacyChatState();
await setMeta({
activeSessionId: undefined,
migratedFromLocalStorage: true,
});
return;
}
const now = Date.now();
const sessionRecord: ChatSessionRecord = {
id: createId(),
title: "新对话",
isTitleManuallyEdited: false,
createdAt: now,
updatedAt: now,
sessionId: legacyState.sessionId,
messages: sanitizeMessages(legacyState.messages),
branchGroups: sanitizeBranchGroups(legacyState.branchGroups),
const payload = (await response.json()) as {
id: string;
title?: string;
is_title_manually_edited?: boolean;
session_id?: string;
messages?: Message[];
is_streaming?: boolean;
run_status?: string;
};
return {
title: normalizeTitle(payload.title),
isTitleManuallyEdited: payload.is_title_manually_edited ?? false,
messages: sanitizeMessages(payload.messages),
sessionId: payload.session_id ?? payload.id,
isStreaming: payload.is_streaming ?? false,
runStatus: payload.run_status,
};
const db = await getDb();
await db.put(SESSION_STORE, sessionRecord);
clearLegacyChatState();
await setMeta({
activeSessionId: sessionRecord.id,
migratedFromLocalStorage: true,
});
};
export const loadActiveChatState = async (): Promise<LoadedChatState> => {
if (typeof window === "undefined") return emptyLoadedChatState();
await migrateLegacyLocalStorage();
const meta = await getMeta();
const db = await getDb();
if (meta?.activeSessionId) {
const activeSession = await db.get(SESSION_STORE, meta.activeSessionId);
if (activeSession) {
return toLoadedChatState(activeSession);
}
}
const latestSession = await getLatestSession();
if (!latestSession) {
return emptyLoadedChatState();
}
await setMeta({
activeSessionId: latestSession.id,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
const createBackendChatSession = async (payload?: {
sessionId?: string;
parentSessionId?: string;
}) => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/session`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: payload?.sessionId,
parent_session_id: payload?.parentSessionId,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
});
if (!response.ok) {
throw new Error(await response.text());
}
const body = (await response.json()) as {
session_id?: string;
};
const sessionId = body.session_id?.trim();
if (!sessionId) {
throw new Error("backend did not return session_id");
}
return sessionId;
};
return toLoadedChatState(latestSession);
const saveBackendChatState = async (
sessionId: string,
state: LoadedChatState,
): Promise<string> => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: normalizeTitle(state.title),
is_title_manually_edited: state.isTitleManuallyEdited ?? false,
messages: sanitizeMessages(state.messages),
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
throw new Error(await response.text());
}
const payload = (await response.json()) as { id?: string; session_id?: string };
return payload.id ?? payload.session_id ?? sessionId;
};
const updateBackendChatSessionTitle = async (
sessionId: string,
title: string,
isTitleManuallyEdited?: boolean,
) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
is_title_manually_edited: isTitleManuallyEdited,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
throw new Error(await response.text());
}
};
const deleteBackendChatSession = async (sessionId: string) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
{
method: "DELETE",
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok && response.status !== 404) {
throw new Error(await response.text());
}
};
export const saveActiveChatState = async (
state: LoadedChatState,
): Promise<string | undefined> => {
if (typeof window === "undefined") return state.storageSessionId;
if (typeof window === "undefined") return state.sessionId;
const hasContent = hasChatContent(state);
const db = await getDb();
const existingSession = state.storageSessionId
? await db.get(SESSION_STORE, state.storageSessionId)
: undefined;
const meta = await getMeta();
if (!hasContent) {
if (state.storageSessionId) {
await db.delete(SESSION_STORE, state.storageSessionId);
}
await setMeta({
activeSessionId: undefined,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
});
if (!hasChatContent(state)) {
return undefined;
}
const now = Date.now();
const storageSessionId = state.storageSessionId ?? createId();
const preferredTitle = state.title?.trim();
const finalTitle = preferredTitle || existingSession?.title || "新对话";
const hasContentChanged =
!existingSession ||
(existingSession && serializeConversationState(existingSession)) !==
serializeConversationState(state);
const shouldAnchorCreatedAtToFirstMessage =
existingSession && !hasChatContent(existingSession) && hasContent;
const nextRecord: ChatSessionRecord = {
id: storageSessionId,
title: finalTitle,
isTitleManuallyEdited:
state.isTitleManuallyEdited ??
existingSession?.isTitleManuallyEdited ??
false,
createdAt: shouldAnchorCreatedAtToFirstMessage
? now
: existingSession?.createdAt ?? now,
updatedAt: hasContentChanged ? now : existingSession?.updatedAt ?? now,
sessionId: state.sessionId,
messages: sanitizeMessages(state.messages),
branchGroups: sanitizeBranchGroups(state.branchGroups),
};
let backendSessionId = state.sessionId;
if (!backendSessionId) {
backendSessionId = await createBackendChatSession();
}
await db.put(SESSION_STORE, nextRecord);
await setMeta({
activeSessionId: storageSessionId,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
const savedSessionId = await saveBackendChatState(backendSessionId, {
...state,
sessionId: backendSessionId,
});
return storageSessionId;
return savedSessionId;
};
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
if (typeof window === "undefined") return [];
await migrateLegacyLocalStorage();
const db = await getDb();
const sessions = await db.getAll(SESSION_STORE);
return sessions.sort(compareSessionsByAnchorTime).map(toSessionSummary);
return await fetchBackendChatSessions();
};
export const updateChatSessionTitle = async (
storageSessionId: string,
sessionId: string,
title: string,
options?: {
isTitleManuallyEdited?: boolean;
@@ -327,67 +249,19 @@ export const updateChatSessionTitle = async (
const normalizedTitle = title.trim();
if (!normalizedTitle) return;
const db = await getDb();
const session = await db.get(SESSION_STORE, storageSessionId);
if (!session) return;
await db.put(SESSION_STORE, {
...session,
title: normalizedTitle,
isTitleManuallyEdited:
options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false,
});
};
export const createEmptyChatSession = async (): Promise<LoadedChatState> => {
if (typeof window === "undefined") return emptyLoadedChatState();
await migrateLegacyLocalStorage();
const now = Date.now();
const session: ChatSessionRecord = {
id: createId(),
title: "新对话",
isTitleManuallyEdited: false,
createdAt: now,
updatedAt: now,
sessionId: undefined,
messages: [],
branchGroups: [],
};
const db = await getDb();
await db.put(SESSION_STORE, session);
const meta = await getMeta();
await setMeta({
activeSessionId: session.id,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
});
return toLoadedChatState(session);
await updateBackendChatSessionTitle(
sessionId,
normalizedTitle,
options?.isTitleManuallyEdited,
);
};
export const loadChatSessionById = async (
sessionId: string,
): Promise<LoadedChatState> => {
if (typeof window === "undefined") return emptyLoadedChatState();
if (typeof window === "undefined") return createEmptyChatState();
await migrateLegacyLocalStorage();
const db = await getDb();
const session = await db.get(SESSION_STORE, sessionId);
if (!session) {
return emptyLoadedChatState();
}
const meta = await getMeta();
await setMeta({
activeSessionId: session.id,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
});
return toLoadedChatState(session);
return await fetchBackendChatSession(sessionId);
};
export const deleteChatSession = async (
@@ -395,19 +269,7 @@ export const deleteChatSession = async (
): Promise<string | undefined> => {
if (typeof window === "undefined") return undefined;
const db = await getDb();
await db.delete(SESSION_STORE, sessionId);
const remainingSessions = await db.getAll(SESSION_STORE);
const nextActiveSession = remainingSessions.sort(
compareSessionsByAnchorTime,
)[0];
const meta = await getMeta();
await setMeta({
activeSessionId: nextActiveSession?.id,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
});
await deleteBackendChatSession(sessionId);
const nextActiveSession = (await listChatSessions())[0];
return nextActiveSession?.id;
};
@@ -0,0 +1,456 @@
import type {
AgentQuestionRequest,
AgentTodoUpdate,
PermissionReply,
StreamEvent,
} from "@/lib/chatStream";
import type {
AgentPermissionRequest,
ChatProgress,
LoadedChatState,
Message,
} from "../GlobalChatbox.types";
import { createId } from "../GlobalChatbox.utils";
export const createPersistedStateKey = (state: LoadedChatState) =>
JSON.stringify({
title: state.title ?? null,
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
sessionId: state.sessionId ?? null,
messages: state.messages,
});
export const upsertProgress = (
progress: ChatProgress[] | undefined,
event: StreamEvent & { type: "progress" },
) => {
const next = [...(progress ?? [])];
const index = next.findIndex((item) => item.id === event.id);
const existing = index >= 0 ? next[index] : undefined;
const now = Date.now();
const startedAt = event.startedAt ?? existing?.startedAt;
const isRunning = event.status === "running";
const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now;
const elapsedMs = isRunning
? event.elapsedMs ??
existing?.elapsedMs ??
(startedAt !== undefined ? Math.max(0, now - startedAt) : undefined)
: undefined;
const elapsedSnapshotAt = isRunning
? event.elapsedMs !== undefined
? now
: existing?.elapsedSnapshotAt ?? now
: undefined;
const durationMs = !isRunning
? event.durationMs ??
existing?.durationMs ??
(startedAt !== undefined && endedAt !== undefined
? Math.max(0, endedAt - startedAt)
: undefined)
: undefined;
const nextItem: ChatProgress = {
id: event.id,
phase: event.phase,
status: event.status,
title: event.title,
detail: event.detail,
startedAt,
endedAt,
elapsedMs,
elapsedSnapshotAt,
durationMs,
};
if (index >= 0) {
next[index] = nextItem;
} else {
next.push(nextItem);
}
return next;
};
export const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
progress?.map((item) => {
if (item.status !== "running") {
return item;
}
const endedAt = Date.now();
return {
...item,
status: "completed" as const,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs:
item.durationMs ??
(item.startedAt !== undefined
? Math.max(0, endedAt - item.startedAt)
: item.elapsedMs),
};
});
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
todoUpdate
? {
...todoUpdate,
todos: todoUpdate.todos.map((todo) =>
todo.status === "pending" || todo.status === "in_progress"
? {
...todo,
status: "cancelled" as const,
updatedAt: Date.now(),
}
: todo,
),
}
: undefined;
export const upsertPermission = (
permissions: AgentPermissionRequest[] | undefined,
event: StreamEvent & { type: "permission_request" },
) => {
const next = [...(permissions ?? [])];
const index = next.findIndex((item) => item.requestId === event.requestId);
const nextItem: AgentPermissionRequest = {
requestId: event.requestId,
sessionId: event.sessionId,
permission: event.permission,
patterns: event.patterns,
target: event.target,
always: event.always,
tool: event.tool,
createdAt: event.createdAt,
status: "pending",
};
if (index >= 0) {
next[index] = {
...next[index],
...nextItem,
status: next[index].status === "submitting" ? "submitting" : nextItem.status,
};
} else {
next.push(nextItem);
}
return next;
};
export const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => {
if (reply === "always") return "approved_always";
if (reply === "once") return "approved_once";
return "rejected";
};
export const isActionableQuestionRequest = (question: {
requestId: string;
tool?: AgentQuestionRequest["tool"];
}) => Boolean(question.requestId && question.requestId !== question.tool?.callID);
export const toQuestionRequest = (
event: StreamEvent & { type: "question_request" },
status: AgentQuestionRequest["status"] = "pending",
): AgentQuestionRequest => ({
requestId: event.requestId,
sessionId: event.sessionId,
questions: event.questions,
tool: event.tool,
createdAt: event.createdAt,
status,
});
export const getQuestionContentSignature = (
questions: AgentQuestionRequest["questions"],
) =>
JSON.stringify(
questions.map((question) => ({
header: question.header,
question: question.question,
options: question.options.map((option) => ({
label: option.label,
description: option.description,
})),
multiple: question.multiple ?? false,
custom: question.custom !== false,
})),
);
export const isSameQuestionRequest = (
question: AgentQuestionRequest,
event: StreamEvent & { type: "question_request" },
) => {
if (question.requestId === event.requestId) return true;
if (question.tool?.callID && event.tool?.callID) {
return question.tool.callID === event.tool.callID;
}
return (
question.status === "pending" &&
question.sessionId === event.sessionId &&
getQuestionContentSignature(question.questions) ===
getQuestionContentSignature(event.questions)
);
};
export const isSameQuestionPair = (
left: AgentQuestionRequest,
right: AgentQuestionRequest,
) => {
if (left.requestId === right.requestId) return true;
if (left.tool?.callID && right.tool?.callID) {
return left.tool.callID === right.tool.callID;
}
return (
left.status === "pending" &&
right.status === "pending" &&
left.sessionId === right.sessionId &&
getQuestionContentSignature(left.questions) ===
getQuestionContentSignature(right.questions)
);
};
export const dedupeQuestionsAcrossMessages = (messages: Message[]) => {
const seen: AgentQuestionRequest[] = [];
let changed = false;
const nextMessages = messages.map((message) => {
if (!message.questions?.length) {
return message;
}
const nextQuestions = message.questions.filter((question) => {
if (seen.some((existing) => isSameQuestionPair(existing, question))) {
changed = true;
return false;
}
seen.push(question);
return true;
});
if (nextQuestions.length === message.questions.length) {
return message;
}
return {
...message,
questions: nextQuestions.length ? nextQuestions : undefined,
};
});
return changed ? nextMessages : messages;
};
export const upsertQuestionAcrossMessages = (
messages: Message[],
event: StreamEvent & { type: "question_request" },
assistantMessageId: string,
) => {
let existing: AgentQuestionRequest | undefined;
for (const message of messages) {
const match = message.questions?.find((question) =>
isSameQuestionRequest(question, event),
);
if (match) {
existing = match;
break;
}
}
const existingStatus: AgentQuestionRequest["status"] | undefined =
existing?.status === "submitting" ? "submitting" : undefined;
const nextQuestion =
existing &&
isActionableQuestionRequest(existing) &&
!isActionableQuestionRequest(event)
? {
...existing,
sessionId: event.sessionId,
questions: event.questions,
tool: event.tool ?? existing.tool,
createdAt: event.createdAt,
status: existingStatus ?? existing.status,
}
: toQuestionRequest(event, existingStatus ?? "pending");
const targetMessageId = existing
? messages.find((message) =>
message.questions?.some((question) => isSameQuestionRequest(question, event)),
)?.id ?? assistantMessageId
: assistantMessageId;
return messages.map((message) => {
const filteredQuestions = message.questions?.filter(
(question) => !isSameQuestionRequest(question, event),
);
if (message.id !== targetMessageId) {
return filteredQuestions?.length === message.questions?.length
? message
: {
...message,
questions: filteredQuestions?.length ? filteredQuestions : undefined,
};
}
const nextQuestions = [...(filteredQuestions ?? []), nextQuestion];
return {
...message,
questions: nextQuestions,
};
});
};
export const applyQuestionResponse = (
questions: AgentQuestionRequest[] | undefined,
event: StreamEvent & { type: "question_response" },
) =>
(questions ?? []).map((question) =>
question.requestId === event.requestId
? {
...question,
status: event.rejected ? "rejected" as const : "answered" as const,
answers: event.answers ?? question.answers,
repliedAt: Date.now(),
error: undefined,
}
: question,
);
export const createTodoUpdateFromEvent = (
event: StreamEvent & { type: "todo_update" },
): AgentTodoUpdate => ({
sessionId: event.sessionId,
messageId: event.messageId,
todos: event.todos,
createdAt: event.createdAt,
});
export const normalizeSessionTodos = (
messages: Message[],
nextTodoUpdate?: AgentTodoUpdate,
targetAssistantMessageId?: string,
) => {
let latestTodoUpdate = nextTodoUpdate;
if (!latestTodoUpdate) {
for (const message of messages) {
if (message.todos) {
latestTodoUpdate = message.todos;
}
}
}
if (!latestTodoUpdate) {
return messages;
}
const targetMessageId =
targetAssistantMessageId ??
[...messages].reverse().find((message) => message.role === "assistant")?.id;
if (!targetMessageId) {
return messages;
}
let changed = false;
const nextMessages = messages.map((message) => {
if (message.id === targetMessageId) {
if (message.todos === latestTodoUpdate) {
return message;
}
changed = true;
return {
...message,
todos: latestTodoUpdate,
};
}
if (!message.todos) {
return message;
}
changed = true;
return {
...message,
todos: undefined,
};
});
return changed ? nextMessages : messages;
};
export const abortOpenPermissionsAfterAbort = (
permissions: AgentPermissionRequest[] | undefined,
) => {
if (!permissions?.length) return permissions;
let changed = false;
const nextPermissions = permissions.map((permission) => {
if (
permission.status !== "pending" &&
permission.status !== "submitting" &&
permission.status !== "error"
) {
return permission;
}
changed = true;
return {
...permission,
status: "aborted" as const,
repliedAt: Date.now(),
error: undefined,
};
});
return changed ? nextPermissions : permissions;
};
export const rejectOpenQuestionsAfterAbort = (
questions: AgentQuestionRequest[] | undefined,
) => {
if (!questions?.length) return questions;
let changed = false;
const nextQuestions = questions.map((question) => {
if (
question.status !== "pending" &&
question.status !== "submitting" &&
question.status !== "error"
) {
return question;
}
changed = true;
return {
...question,
status: "rejected" as const,
repliedAt: Date.now(),
error: undefined,
};
});
return changed ? nextQuestions : questions;
};
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
const completedProgress = completeRunningProgress(message.progress);
const cancelledTodos = cancelRunningTodos(message.todos);
const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions);
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
const hasVisibleOutput =
message.content.trim().length > 0 ||
Boolean(message.artifacts?.length) ||
Boolean(abortedPermissions?.length) ||
Boolean(rejectedQuestions?.length) ||
Boolean(completedProgress?.length) ||
Boolean(cancelledTodos);
if (!hasVisibleOutput) {
return message;
}
return {
...message,
content: message.content || "⚠️ **请求已中断**",
isError: true,
progress: completedProgress,
permissions: abortedPermissions,
questions: rejectedQuestions,
todos: cancelledTodos,
};
};
export const createUserMessage = (content: string): Message => {
const id = createId();
return {
id,
role: "user",
content,
};
};
export const createAssistantMessage = (): Message => ({
id: createId(),
role: "assistant",
content: "",
});
@@ -0,0 +1,401 @@
"use client";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useAgentChatSession } from "./useAgentChatSession";
import {
abortAgentChat,
forkAgentChat,
replyAgentPermission,
replyAgentQuestion,
resumeAgentChatStream,
streamAgentChat,
} from "@/lib/chatStream";
import type { StreamEvent } from "@/lib/chatStream";
jest.mock("@/lib/chatStream", () => ({
abortAgentChat: jest.fn(async () => undefined),
forkAgentChat: jest.fn(async () => "forked-session"),
replyAgentPermission: jest.fn(async () => undefined),
replyAgentQuestion: jest.fn(async () => undefined),
resumeAgentChatStream: jest.fn(async () => undefined),
streamAgentChat: jest.fn(async () => undefined),
}));
const listChatSessions = jest.fn();
const deleteChatSession = jest.fn();
const saveActiveChatState = jest.fn();
const updateChatSessionTitle = jest.fn();
jest.mock("../chatStorage", () => ({
createEmptyChatState: jest.fn(() => ({
title: undefined,
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
})),
deleteChatSession: (...args: unknown[]) => deleteChatSession(...args),
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
loadChatSessionById: jest.fn(async () => ({
title: "已存在会话",
isTitleManuallyEdited: false,
messages: [],
sessionId: "session-loaded",
})),
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
}));
describe("useAgentChatSession", () => {
beforeEach(() => {
listChatSessions.mockReset();
deleteChatSession.mockReset();
saveActiveChatState.mockReset();
updateChatSessionTitle.mockReset();
jest.mocked(abortAgentChat).mockReset();
jest.mocked(forkAgentChat).mockReset();
jest.mocked(replyAgentPermission).mockReset();
jest.mocked(replyAgentQuestion).mockReset();
jest.mocked(resumeAgentChatStream).mockReset();
jest.mocked(streamAgentChat).mockReset();
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
deleteChatSession.mockImplementation(async () => undefined);
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
updateChatSessionTitle.mockImplementation(async () => undefined);
});
describe("useAgentChatSession actions", () => {
it("tracks permission requests and submits replies", async () => {
listChatSessions.mockResolvedValue([]);
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
emitStreamEvent = onEvent;
await new Promise<void>(() => undefined);
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await act(async () => {
void result.current.sendPrompt("删除临时文件");
await Promise.resolve();
});
act(() => {
emitStreamEvent?.({
type: "permission_request",
sessionId: "session-1",
requestId: "perm-1",
permission: "bash",
patterns: ["rm *"],
target: "rm tmp.txt",
always: ["rm *"],
createdAt: 123,
});
});
expect(result.current.messages.at(-1)?.permissions).toEqual([
expect.objectContaining({
requestId: "perm-1",
sessionId: "session-1",
status: "pending",
}),
]);
await act(async () => {
await result.current.replyPermission("perm-1", "once");
});
expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once");
expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual(
expect.objectContaining({
requestId: "perm-1",
status: "approved_once",
}),
);
});
it("finalizes running progress when aborting an active prompt", async () => {
listChatSessions.mockResolvedValue([]);
jest.mocked(streamAgentChat).mockImplementationOnce(
({ onEvent, signal }) =>
new Promise<void>((_, reject) => {
onEvent({
type: "progress",
sessionId: "session-1",
id: "request-received",
phase: "start",
status: "running",
title: "开始分析",
startedAt: 1000,
} satisfies StreamEvent);
onEvent({
type: "todo_update",
sessionId: "session-1",
todos: [
{
id: "todo-1",
content: "分析水位",
status: "in_progress",
},
{
id: "todo-2",
content: "生成建议",
status: "pending",
},
],
createdAt: 1001,
} satisfies StreamEvent);
onEvent({
type: "permission_request",
sessionId: "session-1",
requestId: "perm-abort",
permission: "bash",
patterns: ["npm test"],
target: "npm test",
always: ["npm test"],
createdAt: 1002,
} satisfies StreamEvent);
onEvent({
type: "question_request",
sessionId: "session-1",
requestId: "question-abort",
questions: [
{
header: "范围",
question: "请选择范围",
options: [{ label: "城区", description: "中心城区" }],
},
],
createdAt: 1003,
} satisfies StreamEvent);
signal?.addEventListener("abort", () => {
reject(new Error("aborted"));
});
}),
);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
act(() => {
void result.current.sendPrompt("测试中断");
});
await waitFor(() => expect(result.current.isStreaming).toBe(true));
act(() => {
result.current.abort();
});
await waitFor(() => expect(result.current.isStreaming).toBe(false));
expect(result.current.messages.at(-1)).toEqual(
expect.objectContaining({
role: "assistant",
content: "⚠️ **请求已中断**",
isError: true,
progress: [
expect.objectContaining({
id: "request-received",
status: "completed",
durationMs: expect.any(Number),
endedAt: expect.any(Number),
}),
],
todos: expect.objectContaining({
todos: [
expect.objectContaining({
id: "todo-1",
status: "cancelled",
updatedAt: expect.any(Number),
}),
expect.objectContaining({
id: "todo-2",
status: "cancelled",
updatedAt: expect.any(Number),
}),
],
}),
permissions: [
expect.objectContaining({
requestId: "perm-abort",
status: "aborted",
repliedAt: expect.any(Number),
error: undefined,
}),
],
questions: [
expect.objectContaining({
requestId: "question-abort",
status: "rejected",
repliedAt: expect.any(Number),
error: undefined,
}),
],
}),
);
expect(abortAgentChat).toHaveBeenCalledWith("session-1");
});
it("ignores generated session titles after the title was edited manually", async () => {
listChatSessions.mockResolvedValue([]);
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "session_title",
sessionId: "session-1",
title: "自动标题",
});
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.switchSession("session-loaded");
});
await act(async () => {
await result.current.renameSession("session-loaded", "手动标题");
});
await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled());
await act(async () => {
await result.current.sendPrompt("帮我分析一下");
});
expect(result.current.sessionTitle).toBe("手动标题");
expect(updateChatSessionTitle).not.toHaveBeenCalledWith(
"session-loaded",
"自动标题",
expect.anything(),
);
});
it("does not apply a late generated title to a newly created session", async () => {
listChatSessions.mockResolvedValue([]);
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
let resolveStream: (() => void) | undefined;
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
emitStreamEvent = onEvent;
await new Promise<void>((resolve) => {
resolveStream = resolve;
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await act(async () => {
void result.current.sendPrompt("帮我分析一下");
await Promise.resolve();
});
act(() => {
emitStreamEvent?.({
type: "done",
sessionId: "old-session",
});
});
await waitFor(() => expect(result.current.isStreaming).toBe(false));
act(() => {
result.current.createSession();
});
expect(result.current.sessionTitle).toBe("新对话");
await act(async () => {
emitStreamEvent?.({
type: "session_title",
sessionId: "old-session",
title: "旧请求标题",
});
resolveStream?.();
await Promise.resolve();
});
expect(result.current.sessionTitle).toBe("新对话");
expect(updateChatSessionTitle).toHaveBeenCalledWith(
"old-session",
"旧请求标题",
{ isTitleManuallyEdited: false },
);
});
it("forks a copied conversation from an assistant message", async () => {
listChatSessions.mockResolvedValue([]);
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 firstAssistantMessageId = result.current.messages[1]?.id ?? "";
await act(async () => {
await result.current.createBranch(firstAssistantMessageId);
});
expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2);
expect(result.current.activeSessionId).toBe("forked-session");
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0]).toEqual(
expect.objectContaining({
role: "user",
content: "第一轮",
}),
);
expect(result.current.messages[1]).toEqual(
expect.objectContaining({
role: "assistant",
}),
);
expect(streamAgentChat).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,791 @@
"use client";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useAgentChatSession } from "./useAgentChatSession";
import {
abortAgentChat,
forkAgentChat,
replyAgentPermission,
replyAgentQuestion,
resumeAgentChatStream,
streamAgentChat,
} from "@/lib/chatStream";
import type { StreamEvent } from "@/lib/chatStream";
jest.mock("@/lib/chatStream", () => ({
abortAgentChat: jest.fn(async () => undefined),
forkAgentChat: jest.fn(async () => "forked-session"),
replyAgentPermission: jest.fn(async () => undefined),
replyAgentQuestion: jest.fn(async () => undefined),
resumeAgentChatStream: jest.fn(async () => undefined),
streamAgentChat: jest.fn(async () => undefined),
}));
const listChatSessions = jest.fn();
const deleteChatSession = jest.fn();
const saveActiveChatState = jest.fn();
const updateChatSessionTitle = jest.fn();
jest.mock("../chatStorage", () => ({
createEmptyChatState: jest.fn(() => ({
title: undefined,
isTitleManuallyEdited: false,
messages: [],
sessionId: undefined,
})),
deleteChatSession: (...args: unknown[]) => deleteChatSession(...args),
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
loadChatSessionById: jest.fn(async () => ({
title: "已存在会话",
isTitleManuallyEdited: false,
messages: [],
sessionId: "session-loaded",
})),
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
}));
describe("useAgentChatSession", () => {
beforeEach(() => {
listChatSessions.mockReset();
deleteChatSession.mockReset();
saveActiveChatState.mockReset();
updateChatSessionTitle.mockReset();
jest.mocked(abortAgentChat).mockReset();
jest.mocked(forkAgentChat).mockReset();
jest.mocked(replyAgentPermission).mockReset();
jest.mocked(replyAgentQuestion).mockReset();
jest.mocked(resumeAgentChatStream).mockReset();
jest.mocked(streamAgentChat).mockReset();
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
deleteChatSession.mockImplementation(async () => undefined);
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
updateChatSessionTitle.mockImplementation(async () => undefined);
});
describe("useAgentChatSession lifecycle and resume", () => {
it("does not add a new empty session to history until there is actual chat content", async () => {
listChatSessions.mockResolvedValue([]);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
act(() => {
void result.current.createSession();
});
await waitFor(() => expect(result.current.sessionTitle).toBe("新对话"));
expect(result.current.chatSessions).toEqual([]);
expect(result.current.activeSessionId).toBeUndefined();
expect(result.current.messages).toEqual([]);
expect(result.current.isStreaming).toBe(false);
expect(listChatSessions).toHaveBeenCalledTimes(1);
});
it("keeps existing history entries when creating a blank new session", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-1",
title: "已有会话",
createdAt: 1,
updatedAt: 1,
},
]);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
act(() => {
void result.current.createSession();
});
expect(result.current.chatSessions).toEqual([
{
id: "session-1",
title: "已有会话",
createdAt: 1,
updatedAt: 1,
},
]);
});
it("removes a deleted history entry before the backend delete finishes", async () => {
const initialSessions = [
{
id: "session-1",
title: "第一段会话",
createdAt: 2,
updatedAt: 2,
},
{
id: "session-2",
title: "第二段会话",
createdAt: 1,
updatedAt: 1,
},
];
let resolveDelete: ((nextActiveSessionId?: string) => void) | undefined;
listChatSessions.mockResolvedValue(initialSessions);
deleteChatSession.mockImplementationOnce(
() =>
new Promise<string | undefined>((resolve) => {
resolveDelete = resolve;
}),
);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
act(() => {
void result.current.removeSession("session-2");
});
expect(result.current.chatSessions).toEqual([
expect.objectContaining({ id: "session-1" }),
]);
listChatSessions.mockResolvedValue([
{
id: "session-1",
title: "第一段会话",
createdAt: 2,
updatedAt: 2,
},
]);
await act(async () => {
resolveDelete?.();
await Promise.resolve();
});
await waitFor(() =>
expect(result.current.chatSessions).toEqual([
expect.objectContaining({ id: "session-1" }),
]),
);
});
it("persists a new conversation only after the stream is done", async () => {
listChatSessions.mockResolvedValue([]);
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
emitStreamEvent = onEvent;
await new Promise<void>(() => undefined);
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
jest.useFakeTimers();
try {
await act(async () => {
void result.current.sendPrompt("第一条消息");
await Promise.resolve();
});
expect(result.current.isStreaming).toBe(true);
await act(async () => {
jest.advanceTimersByTime(200);
});
expect(saveActiveChatState).not.toHaveBeenCalled();
act(() => {
emitStreamEvent?.({
type: "token",
sessionId: "chat-stream-1",
content: "收到",
});
});
await act(async () => {
jest.advanceTimersByTime(200);
});
expect(saveActiveChatState).not.toHaveBeenCalled();
act(() => {
emitStreamEvent?.({
type: "done",
sessionId: "chat-stream-1",
});
});
await act(async () => {
jest.advanceTimersByTime(200);
});
await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1));
expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({
sessionId: "chat-stream-1",
messages: [
expect.objectContaining({ role: "user", content: "第一条消息" }),
expect.objectContaining({ role: "assistant", content: "收到" }),
],
});
} finally {
jest.useRealTimers();
}
});
it("shows shared todo state only on the latest assistant message in a session", async () => {
listChatSessions.mockResolvedValue([]);
jest.mocked(streamAgentChat)
.mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "todo_update",
sessionId: "session-1",
todos: [
{
id: "todo-1",
content: "创建任务列表",
status: "in_progress",
},
],
createdAt: 1000,
});
onEvent({
type: "done",
sessionId: "session-1",
});
})
.mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "todo_update",
sessionId: "session-1",
todos: [
{
id: "todo-1",
content: "创建任务列表",
status: "completed",
},
{
id: "todo-2",
content: "更新任务状态",
status: "in_progress",
},
],
createdAt: 2000,
});
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("创建任务");
});
await waitFor(() => expect(result.current.isStreaming).toBe(false));
await act(async () => {
await result.current.sendPrompt("更新任务");
});
await waitFor(() => expect(result.current.isStreaming).toBe(false));
const assistantMessages = result.current.messages.filter(
(message) => message.role === "assistant",
);
expect(assistantMessages).toHaveLength(2);
expect(assistantMessages[0].todos).toBeUndefined();
expect(assistantMessages[1].todos).toEqual(
expect.objectContaining({
sessionId: "session-1",
createdAt: 2000,
todos: [
expect.objectContaining({
id: "todo-1",
status: "completed",
}),
expect.objectContaining({
id: "todo-2",
status: "in_progress",
}),
],
}),
);
});
it("hydrates a backend streaming session and resumes its stream", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
runStatus: "running",
},
]);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
expect(result.current.isStreaming).toBe(true);
expect(result.current.activeSessionId).toBe("session-loaded");
expect(resumeAgentChatStream).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "session-loaded",
}),
);
});
it("updates resumed messages from state, token, and done events", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
},
]);
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "state",
sessionId: "session-loaded",
messages: [
{ id: "u1", role: "user", content: "继续分析" },
{ id: "a1", role: "assistant", content: "已有" },
],
isStreaming: true,
runStatus: "running",
});
onEvent({
type: "token",
sessionId: "session-loaded",
content: "输出",
});
onEvent({
type: "done",
sessionId: "session-loaded",
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await waitFor(() => expect(result.current.isStreaming).toBe(false));
expect(result.current.messages).toEqual([
expect.objectContaining({ id: "u1", role: "user", content: "继续分析" }),
expect.objectContaining({ id: "a1", role: "assistant", content: "已有输出" }),
]);
});
it("applies question responses to the message that owns the request", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
},
]);
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "state",
sessionId: "session-loaded",
messages: [
{ id: "u1", role: "user", content: "继续分析" },
{
id: "a1",
role: "assistant",
content: "需要确认",
questions: [
{
requestId: "q-1",
sessionId: "session-loaded",
questions: [
{
header: "范围",
question: "选择范围",
options: [],
custom: true,
},
],
createdAt: 123,
status: "pending",
},
],
},
{ id: "a2", role: "assistant", content: "后续消息" },
],
isStreaming: true,
runStatus: "running",
});
onEvent({
type: "question_response",
sessionId: "session-loaded",
requestId: "q-1",
answers: [["城区"]],
rejected: false,
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
expect(result.current.messages[1].questions?.[0]).toEqual(
expect.objectContaining({
requestId: "q-1",
status: "answered",
answers: [["城区"]],
}),
);
expect(result.current.messages[2].questions).toBeUndefined();
});
it("deduplicates question requests across assistant messages", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
},
]);
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "state",
sessionId: "session-loaded",
messages: [
{ id: "u1", role: "user", content: "继续分析" },
{
id: "a1",
role: "assistant",
content: "需要确认",
questions: [
{
requestId: "question-1",
sessionId: "session-loaded",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
tool: {
messageID: "message-1",
callID: "call-1",
},
createdAt: 123,
status: "pending",
},
],
},
{ id: "a2", role: "assistant", content: "后续消息" },
],
isStreaming: true,
runStatus: "running",
});
onEvent({
type: "question_request",
sessionId: "session-loaded",
requestId: "call-1",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
tool: {
messageID: "message-1",
callID: "call-1",
},
createdAt: 456,
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
const allQuestions = result.current.messages.flatMap(
(message) => message.questions ?? [],
);
expect(allQuestions).toHaveLength(1);
expect(result.current.messages[1].questions?.[0]).toEqual(
expect.objectContaining({
requestId: "question-1",
tool: expect.objectContaining({ callID: "call-1" }),
}),
);
expect(result.current.messages[2].questions).toBeUndefined();
});
it("keeps the actionable question request id when a tool-part duplicate arrives later", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
},
]);
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "state",
sessionId: "session-loaded",
messages: [
{ id: "u1", role: "user", content: "继续分析" },
{
id: "a1",
role: "assistant",
content: "需要确认",
questions: [
{
requestId: "question-1",
sessionId: "session-loaded",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
tool: {
messageID: "message-1",
callID: "call-1",
},
createdAt: 123,
status: "pending",
},
],
},
],
isStreaming: true,
runStatus: "running",
});
onEvent({
type: "question_request",
sessionId: "session-loaded",
requestId: "call-1",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
tool: {
messageID: "message-1",
callID: "call-1",
},
createdAt: 456,
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
const allQuestions = result.current.messages.flatMap(
(message) => message.questions ?? [],
);
expect(allQuestions).toHaveLength(1);
expect(allQuestions[0]).toEqual(
expect.objectContaining({
requestId: "question-1",
tool: expect.objectContaining({ callID: "call-1" }),
}),
);
});
it("deduplicates persisted duplicate questions from state events", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
},
]);
const duplicateQuestion = {
sessionId: "session-loaded",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
tool: {
messageID: "message-1",
callID: "call-1",
},
createdAt: 123,
status: "pending" as const,
};
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "state",
sessionId: "session-loaded",
messages: [
{ id: "u1", role: "user", content: "继续分析" },
{
id: "a1",
role: "assistant",
content: "需要确认",
questions: [{ ...duplicateQuestion, requestId: "question-1" }],
},
{
id: "a2",
role: "assistant",
content: "后续消息",
questions: [{ ...duplicateQuestion, requestId: "call-1" }],
},
],
isStreaming: true,
runStatus: "running",
});
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
expect(
result.current.messages.flatMap((message) => message.questions ?? []),
).toHaveLength(1);
expect(result.current.messages[1].questions).toHaveLength(1);
expect(result.current.messages[2].questions).toBeUndefined();
});
it("aborts a resumed streaming session through the backend abort endpoint", async () => {
listChatSessions.mockResolvedValue([
{
id: "session-streaming",
title: "运行中",
createdAt: 1,
updatedAt: 2,
isStreaming: true,
},
]);
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async () => {
await new Promise<void>(() => undefined);
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isStreaming).toBe(true));
act(() => {
result.current.abort();
});
expect(abortAgentChat).toHaveBeenCalledWith("session-loaded");
});
});
});
@@ -0,0 +1,2 @@
// Tests for useAgentChatSession are split by behavior boundary.
// See useAgentChatSession.lifecycle.test.tsx and useAgentChatSession.actions.test.tsx.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
import type { AgentApprovalMode, AgentModel, StreamEvent } from "@/lib/chatStream";
import type { AgentArtifact, Message } from "../GlobalChatbox.types";
export type UseAgentChatSessionOptions = {
projectId?: string | null;
onToolCall: (
event: StreamEvent & { type: "tool_call" },
options: {
assistantMessageId: string;
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
},
) => void;
onBeforeSend?: () => void;
getModel?: () => AgentModel;
getApprovalMode?: () => AgentApprovalMode;
};
export type PromptRunOptions = {
prompt: string;
sessionIdOverride?: string;
preparedMessages?: Message[];
userMessage?: Message;
assistantMessage?: Message;
};
@@ -5,6 +5,11 @@ import { useCallback } from "react";
import { useChatToolStore, type ChatToolAction } from "@/store/chatToolStore";
import type { StreamEvent } from "@/lib/chatStream";
import type { AgentArtifact, AgentArtifactKind } from "../GlobalChatbox.types";
import {
APPLY_LAYER_STYLE_TOOL,
describeApplyLayerStyle,
parseApplyLayerStylePayload,
} from "../toolCallStyleHelpers";
type ToolCallEvent = StreamEvent & { type: "tool_call" };
@@ -248,6 +253,23 @@ const buildToolAction = (
};
}
if (tool === APPLY_LAYER_STYLE_TOOL) {
const payload = parseApplyLayerStylePayload(params);
return {
action: payload
? {
type: "apply_layer_style",
layerId: payload.layerId,
resetToDefault: payload.resetToDefault,
styleConfig: payload.styleConfig,
}
: null,
kind: "map",
title: payload?.resetToDefault ? "重置图层样式" : "应用图层样式",
description: payload ? describeApplyLayerStyle(payload) : "图层样式",
};
}
return {
action: null,
kind: "tool",
+150
View File
@@ -0,0 +1,150 @@
import type { StyleConfig, DefaultLayerStyleId } from "@components/olmap/core/Controls/styleEditorTypes";
export type ApplyLayerStyleActionPayload = {
layerId: DefaultLayerStyleId;
resetToDefault: boolean;
styleConfig?: Partial<StyleConfig>;
};
export const APPLY_LAYER_STYLE_TOOL = "apply_layer_style";
const LAYER_LABELS: Record<DefaultLayerStyleId, string> = {
junctions: "节点",
pipes: "管道",
};
const asString = (value: unknown): string | undefined =>
typeof value === "string" && value.trim() ? value.trim() : undefined;
const asNumber = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value)
? value
: typeof value === "string" && value.trim() && Number.isFinite(Number(value))
? Number(value)
: undefined;
const asBoolean = (value: unknown): boolean | undefined =>
typeof value === "boolean"
? value
: typeof value === "string"
? value === "true"
? true
: value === "false"
? false
: undefined
: undefined;
const asNumberArray = (value: unknown): number[] | undefined =>
Array.isArray(value)
? value
.map((item) => asNumber(item))
.filter((item): item is number => item !== undefined)
: undefined;
const asStringArray = (value: unknown): string[] | undefined =>
Array.isArray(value)
? value
.map((item) => asString(item))
.filter((item): item is string => item !== undefined)
: undefined;
export const normalizeStyleLayerId = (value: unknown): DefaultLayerStyleId | null => {
const normalized = asString(value)?.toLowerCase();
if (normalized === "junctions" || normalized === "pipes") {
return normalized;
}
return null;
};
export const getStyleLayerLabel = (layerId: DefaultLayerStyleId): string =>
LAYER_LABELS[layerId];
export const parseApplyLayerStylePayload = (
params: Record<string, unknown>,
): ApplyLayerStyleActionPayload | null => {
const layerId = normalizeStyleLayerId(params.layer_id ?? params.layerId);
if (!layerId) {
return null;
}
const resetToDefault = Boolean(
asBoolean(params.reset_to_default ?? params.resetToDefault),
);
const rawStyleConfig =
params.style_config && typeof params.style_config === "object"
? (params.style_config as Record<string, unknown>)
: params.styleConfig && typeof params.styleConfig === "object"
? (params.styleConfig as Record<string, unknown>)
: null;
const styleConfig: Partial<StyleConfig> | undefined = rawStyleConfig
? {
property: asString(rawStyleConfig.property),
classificationMethod: asString(
rawStyleConfig.classification_method ?? rawStyleConfig.classificationMethod,
),
segments: asNumber(rawStyleConfig.segments),
minSize: asNumber(rawStyleConfig.min_size ?? rawStyleConfig.minSize),
maxSize: asNumber(rawStyleConfig.max_size ?? rawStyleConfig.maxSize),
minStrokeWidth: asNumber(
rawStyleConfig.min_stroke_width ?? rawStyleConfig.minStrokeWidth,
),
maxStrokeWidth: asNumber(
rawStyleConfig.max_stroke_width ?? rawStyleConfig.maxStrokeWidth,
),
fixedStrokeWidth: asNumber(
rawStyleConfig.fixed_stroke_width ?? rawStyleConfig.fixedStrokeWidth,
),
colorType: asString(rawStyleConfig.color_type ?? rawStyleConfig.colorType),
singlePaletteIndex: asNumber(
rawStyleConfig.single_palette_index ?? rawStyleConfig.singlePaletteIndex,
),
gradientPaletteIndex: asNumber(
rawStyleConfig.gradient_palette_index ?? rawStyleConfig.gradientPaletteIndex,
),
rainbowPaletteIndex: asNumber(
rawStyleConfig.rainbow_palette_index ?? rawStyleConfig.rainbowPaletteIndex,
),
showLabels: asBoolean(rawStyleConfig.show_labels ?? rawStyleConfig.showLabels),
showId: asBoolean(rawStyleConfig.show_id ?? rawStyleConfig.showId),
opacity: asNumber(rawStyleConfig.opacity),
adjustWidthByProperty: asBoolean(
rawStyleConfig.adjust_width_by_property ??
rawStyleConfig.adjustWidthByProperty,
),
customBreaks: asNumberArray(
rawStyleConfig.custom_breaks ?? rawStyleConfig.customBreaks,
),
customColors: asStringArray(
rawStyleConfig.custom_colors ?? rawStyleConfig.customColors,
),
}
: undefined;
const hasStyleOverrides =
styleConfig &&
Object.values(styleConfig).some((value) =>
Array.isArray(value) ? value.length > 0 : value !== undefined,
);
if (!resetToDefault && !hasStyleOverrides) {
return null;
}
return {
layerId,
resetToDefault,
styleConfig: hasStyleOverrides ? styleConfig : undefined,
};
};
export const describeApplyLayerStyle = (
payload: ApplyLayerStyleActionPayload,
): string => {
const layerLabel = getStyleLayerLabel(payload.layerId);
if (payload.resetToDefault) {
return `${layerLabel} · 重置默认样式`;
}
const property = payload.styleConfig?.property;
return property ? `${layerLabel} · ${property}` : `${layerLabel} · 应用样式`;
};
@@ -59,6 +59,23 @@ interface TimelineProps {
schemeName?: string;
}
const timelineIconButtonSx = {
width: 32,
height: 32,
borderRadius: "50%",
flexShrink: 0,
overflow: "hidden",
"&:hover": {
borderRadius: "50%",
},
"&.Mui-focusVisible": {
borderRadius: "50%",
},
"& .MuiTouchRipple-root": {
borderRadius: "50%",
},
} as const;
const Timeline: React.FC<TimelineProps> = ({
disableDateSelection = false,
}) => {
@@ -445,7 +462,7 @@ const Timeline: React.FC<TimelineProps> = ({
};
return (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[920px] opacity-90 hover:opacity-100 transition-opacity duration-300">
<div className="absolute bottom-4 left-1/2 z-10 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100">
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale="zh-cn"
@@ -481,6 +498,7 @@ const Timeline: React.FC<TimelineProps> = ({
onClick={handleDayStepBackward}
size="small"
disabled={disableDateSelection}
sx={timelineIconButtonSx}
>
<FiSkipBack />
</IconButton>
@@ -517,6 +535,7 @@ const Timeline: React.FC<TimelineProps> = ({
selectedDateTime.toDateString() ===
new Date().toDateString()
}
sx={timelineIconButtonSx}
>
<FiSkipForward />
</IconButton>
@@ -545,6 +564,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="primary"
onClick={handleStepBackward}
size="small"
sx={timelineIconButtonSx}
>
<TbArrowBackUp />
</IconButton>
@@ -555,6 +575,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="primary"
onClick={isPlaying ? handlePause : handlePlay}
size="small"
sx={timelineIconButtonSx}
>
{isPlaying ? <Pause /> : <PlayArrow />}
</IconButton>
@@ -565,6 +586,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="primary"
onClick={handleStepForward}
size="small"
sx={timelineIconButtonSx}
>
<TbArrowForwardUp />
</IconButton>
@@ -575,6 +597,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="secondary"
onClick={handleStop}
size="small"
sx={timelineIconButtonSx}
>
<Stop />
</IconButton>
@@ -37,6 +37,8 @@ const LayerControl: React.FC = () => {
const deckLayers = data?.deckLayers ?? (deckLayer ? [deckLayer] : []);
const isContourLayerAvailable = data?.isContourLayerAvailable;
const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable;
const showContourLayer = data?.showContourLayer;
const showWaterflowLayer = data?.showWaterflowLayer;
const setShowWaterflowLayer = data?.setShowWaterflowLayer;
const setShowContourLayer = data?.setShowContourLayer;
@@ -46,6 +48,14 @@ const LayerControl: React.FC = () => {
if (!map || !data) return [];
const items: LayerItem[] = [];
const upsertLayerItem = (nextItem: LayerItem) => {
const index = items.findIndex((item) => item.id === nextItem.id);
if (index >= 0) {
items[index] = nextItem;
return;
}
items.push(nextItem);
};
map.getLayers().getArray().forEach((layer) => {
if (
@@ -56,7 +66,7 @@ const LayerControl: React.FC = () => {
const value = layer.get("value");
const name = layer.get("name");
if (value) {
items.push({
upsertLayerItem({
id: value,
name: name || value,
visible: layer.getVisible(),
@@ -80,7 +90,7 @@ const LayerControl: React.FC = () => {
return;
}
items.push({
upsertLayerItem({
id: layer.props.id,
name: layer.props.name,
visible:
@@ -91,6 +101,30 @@ const LayerControl: React.FC = () => {
});
}
if (isWaterflowLayerAvailable) {
upsertLayerItem({
id: "waterflowLayer",
name: "水流",
visible:
deckLayer?.getDeckLayerVisible("waterflowLayer") ?? showWaterflowLayer ?? false,
type: "deck",
layerRef: deckLayer?.getDeckLayerById("waterflowLayer") ?? null,
});
}
if (isContourLayerAvailable) {
upsertLayerItem({
id: "junctionContourLayer",
name: "等值线",
visible:
deckLayer?.getDeckLayerVisible("junctionContourLayer") ??
showContourLayer ??
false,
type: "deck",
layerRef: deckLayer?.getDeckLayerById("junctionContourLayer") ?? null,
});
}
return items
.filter((item) => LAYER_ORDER.includes(item.id))
.sort((a, b) => LAYER_ORDER.indexOf(a.id) - LAYER_ORDER.indexOf(b.id));
@@ -100,6 +134,8 @@ const LayerControl: React.FC = () => {
deckLayer,
isContourLayerAvailable,
isWaterflowLayerAvailable,
showContourLayer,
showWaterflowLayer,
refreshKey,
]);
@@ -126,7 +162,7 @@ const LayerControl: React.FC = () => {
.filter((layer) => layer.get("value") === item.id)
.forEach((layer) => layer.setVisible(checked));
});
} else if (item.type === "deck" && deckLayers.length > 0) {
} else if (item.type === "deck") {
deckLayers.forEach((targetDeckLayer) => {
targetDeckLayer.setDeckLayerVisible(item.id, checked);
});
@@ -0,0 +1,590 @@
import ApplyIcon from "@mui/icons-material/Check";
import ColorLensIcon from "@mui/icons-material/ColorLens";
import ResetIcon from "@mui/icons-material/Refresh";
import {
Box,
Button,
Checkbox,
FormControl,
FormControlLabel,
InputLabel,
MenuItem,
Select,
Slider,
TextField,
Typography,
} from "@mui/material";
import React from "react";
import {
CLASSIFICATION_METHODS,
COLOR_TYPE_OPTIONS,
GRADIENT_PALETTES,
RAINBOW_PALETTES,
SINGLE_COLOR_PALETTES,
} from "./styleEditorPresets";
import { StyleEditorFormProps } from "./styleEditorTypes";
import {
getSizePreviewColors,
hexToRgba,
resolveStyleColors,
rgbaToHex,
} from "./styleEditorUtils";
const StyleEditorForm: React.FC<StyleEditorFormProps> = ({
renderLayers,
selectedRenderLayer,
styleConfig,
setStyleConfig,
availableProperties,
onLayerChange,
onPropertyChange,
onClassificationMethodChange,
onSegmentsChange,
onCustomBreakChange,
onCustomBreakBlur,
onColorTypeChange,
onApply,
onReset,
}) => {
const renderColorSetting = () => {
if (styleConfig.colorType === "single") {
return (
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
<InputLabel></InputLabel>
<Select
value={styleConfig.singlePaletteIndex}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
singlePaletteIndex: Number(e.target.value),
}))
}
>
{SINGLE_COLOR_PALETTES.map((palette, index) => (
<MenuItem key={index} value={index}>
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Box
sx={{
width: "80%",
height: 16,
borderRadius: 2,
background: palette.color,
marginRight: 1,
border: "1px solid #ccc",
}}
/>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
);
}
if (styleConfig.colorType === "gradient") {
return (
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
<InputLabel></InputLabel>
<Select
value={styleConfig.gradientPaletteIndex}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
gradientPaletteIndex: Number(e.target.value),
}))
}
>
{GRADIENT_PALETTES.map((palette, index) => {
const previewColors = resolveStyleColors(
{ ...styleConfig, colorType: "gradient", gradientPaletteIndex: index },
styleConfig.segments + 1
);
return (
<MenuItem key={index} value={index}>
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Box
sx={{
width: "80%",
height: 16,
borderRadius: 2,
display: "flex",
overflow: "hidden",
marginRight: 1,
border: "1px solid #ccc",
}}
>
{previewColors.map((color, colorIndex) => (
<Box
key={colorIndex}
sx={{ flex: 1, backgroundColor: color }}
/>
))}
</Box>
</Box>
</MenuItem>
);
})}
</Select>
</FormControl>
);
}
if (styleConfig.colorType === "rainbow") {
return (
<FormControl variant="standard" fullWidth margin="dense" className="mt-3">
<InputLabel></InputLabel>
<Select
value={styleConfig.rainbowPaletteIndex}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
rainbowPaletteIndex: Number(e.target.value),
}))
}
>
{RAINBOW_PALETTES.map((palette, index) => {
const previewColors = Array.from(
{ length: styleConfig.segments + 1 },
(_, colorIndex) => palette.colors[colorIndex % palette.colors.length]
);
return (
<MenuItem key={index} value={index}>
<Box width="100%" sx={{ display: "flex", alignItems: "center" }}>
<Typography sx={{ marginRight: 1 }}>{palette.name}</Typography>
<Box
sx={{
width: "60%",
height: 16,
borderRadius: 2,
display: "flex",
border: "1px solid #ccc",
overflow: "hidden",
}}
>
{previewColors.map((color, colorIndex) => (
<Box
key={colorIndex}
sx={{ flex: 1, backgroundColor: color }}
/>
))}
</Box>
</Box>
</MenuItem>
);
})}
</Select>
</FormControl>
);
}
if (styleConfig.colorType === "custom") {
return (
<Box className="mt-3">
<Typography variant="subtitle2" gutterBottom>
</Typography>
<Box
className="flex flex-col gap-2"
sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "4px" }}
>
{Array.from({ length: styleConfig.segments }).map((_, index) => {
const color = styleConfig.customColors?.[index] || "rgba(0,0,0,1)";
return (
<Box key={index} className="flex items-center gap-2">
<Typography variant="caption" sx={{ width: 40 }}>
{index + 1}
</Typography>
<input
type="color"
value={rgbaToHex(color)}
onChange={(e) => {
const nextColor = hexToRgba(e.target.value);
setStyleConfig((prev) => {
const nextColors = [...(prev.customColors || [])];
while (nextColors.length < prev.segments) {
nextColors.push("rgba(0,0,0,1)");
}
nextColors[index] = nextColor;
return { ...prev, customColors: nextColors };
});
}}
style={{
width: "100%",
height: "32px",
cursor: "pointer",
border: "1px solid #ccc",
borderRadius: "4px",
}}
/>
</Box>
);
})}
</Box>
</Box>
);
}
return null;
};
const renderSizeSetting = () => {
const previewColors = getSizePreviewColors(styleConfig);
if (selectedRenderLayer?.get("type") === "point") {
return (
<Box className="mt-3">
<Typography gutterBottom>
: {styleConfig.minSize} - {styleConfig.maxSize}
</Typography>
<Box className="flex items-center gap-4">
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.minSize}
onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, minSize: value as number }))
}
min={2}
max={8}
step={1}
size="small"
/>
</Box>
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.maxSize}
onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, maxSize: value as number }))
}
min={10}
max={16}
step={1}
size="small"
/>
</Box>
</Box>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box
sx={{
width: styleConfig.minSize,
height: styleConfig.minSize,
borderRadius: "50%",
backgroundColor: previewColors[0],
}}
/>
<Typography variant="caption"></Typography>
<Box
sx={{
width: styleConfig.maxSize,
height: styleConfig.maxSize,
borderRadius: "50%",
backgroundColor: previewColors[previewColors.length - 1],
}}
/>
</Box>
</Box>
);
}
if (selectedRenderLayer?.get("type") === "linestring") {
return (
<Box className="mt-3">
<FormControlLabel
control={
<Checkbox
checked={styleConfig.adjustWidthByProperty}
onChange={(e) =>
setStyleConfig((prev) => ({
...prev,
adjustWidthByProperty: e.target.checked,
}))
}
disabled={styleConfig.colorType === "single"}
/>
}
label="根据数值分段调整线条宽度"
/>
{styleConfig.adjustWidthByProperty ? (
<>
<Typography gutterBottom>
线: {styleConfig.minStrokeWidth} - {styleConfig.maxStrokeWidth}
px
</Typography>
<Box className="flex items-center gap-4">
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.minStrokeWidth}
onChange={(_, value) =>
setStyleConfig((prev) => ({
...prev,
minStrokeWidth: value as number,
}))
}
min={1}
max={4}
step={0.5}
size="small"
/>
</Box>
<Box className="flex-1">
<Typography variant="caption" gutterBottom>
</Typography>
<Slider
value={styleConfig.maxStrokeWidth}
onChange={(_, value) =>
setStyleConfig((prev) => ({
...prev,
maxStrokeWidth: value as number,
}))
}
min={6}
max={12}
step={0.5}
size="small"
/>
</Box>
</Box>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box
sx={{
width: 50,
height: styleConfig.minStrokeWidth,
backgroundColor: previewColors[0],
border: `1px solid ${previewColors[0]}`,
borderRadius: 1,
}}
/>
<Typography variant="caption"></Typography>
<Box
sx={{
width: 50,
height: styleConfig.maxStrokeWidth,
backgroundColor: previewColors[previewColors.length - 1],
border: `1px solid ${previewColors[previewColors.length - 1]}`,
borderRadius: 1,
}}
/>
</Box>
</>
) : (
<>
<Typography gutterBottom>
线: {styleConfig.fixedStrokeWidth}px
</Typography>
<Slider
value={styleConfig.fixedStrokeWidth}
onChange={(_, value) =>
setStyleConfig((prev) => ({
...prev,
fixedStrokeWidth: value as number,
}))
}
min={1}
max={10}
step={0.5}
size="small"
/>
<Box className="flex items-center gap-2 mt-2 p-2 bg-gray-50 rounded">
<Typography variant="caption">:</Typography>
<Box
sx={{
width: 50,
height: styleConfig.fixedStrokeWidth,
backgroundColor: previewColors[0],
border: `1px solid ${previewColors[0]}`,
borderRadius: 1,
}}
/>
</Box>
</>
)}
</Box>
);
}
return null;
};
return (
<div className="absolute top-20 left-4 bg-white p-4 rounded-xl shadow-lg opacity-95 hover:opacity-100 transition-opacity w-80 z-1300">
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel></InputLabel>
<Select
value={selectedRenderLayer ? renderLayers.indexOf(selectedRenderLayer) : ""}
onChange={(e) => onLayerChange(e.target.value as number)}
>
{renderLayers.map((layer, index) => (
<MenuItem key={index} value={index}>
{layer.get("name")}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel></InputLabel>
<Select
value={styleConfig.property}
onChange={(e) => onPropertyChange(e.target.value)}
disabled={!selectedRenderLayer}
>
{availableProperties.map((property) => (
<MenuItem key={property.name} value={property.value}>
{property.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel></InputLabel>
<Select
value={styleConfig.classificationMethod}
onChange={(e) => onClassificationMethodChange(e.target.value)}
>
{CLASSIFICATION_METHODS.map((method) => (
<MenuItem key={method.value} value={method.value}>
{method.name}
</MenuItem>
))}
</Select>
</FormControl>
<Box className="mt-3">
<Typography gutterBottom>: {styleConfig.segments}</Typography>
<Slider
value={styleConfig.segments}
onChange={(_, value) => onSegmentsChange(value as number)}
min={2}
max={10}
step={1}
marks
size="small"
/>
</Box>
{styleConfig.classificationMethod === "custom_breaks" && (
<Box className="mt-3 p-2 bg-gray-50 rounded">
<Typography variant="subtitle2" gutterBottom>
{">="} 0
</Typography>
<Box
className="flex flex-col gap-2"
sx={{ maxHeight: "160px", overflowY: "auto", paddingTop: "12px" }}
>
{Array.from({ length: styleConfig.segments }).map((_, index) => (
<TextField
key={index}
label={`阈值 ${index + 1}`}
type="number"
size="small"
slotProps={{ input: { inputProps: { min: 0, step: 0.1 } } }}
value={styleConfig.customBreaks?.[index] ?? ""}
onChange={(e) => onCustomBreakChange(index, e.target.value)}
onBlur={onCustomBreakBlur}
/>
))}
</Box>
</Box>
)}
<FormControl variant="standard" fullWidth margin="dense">
<InputLabel>
<ColorLensIcon className="mr-1" />
</InputLabel>
<Select
value={styleConfig.colorType}
onChange={(e) => onColorTypeChange(e.target.value)}
>
{COLOR_TYPE_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
{renderColorSetting()}
</FormControl>
{renderSizeSetting()}
<Box className="mt-3">
<Typography gutterBottom>
: {(styleConfig.opacity * 100).toFixed(0)}%
</Typography>
<Slider
value={styleConfig.opacity}
onChange={(_, value) =>
setStyleConfig((prev) => ({ ...prev, opacity: value as number }))
}
min={0.1}
max={1}
step={0.05}
size="small"
/>
</Box>
<FormControlLabel
control={
<Checkbox
checked={styleConfig.showId}
onChange={(e) =>
setStyleConfig((prev) => ({ ...prev, showId: e.target.checked }))
}
/>
}
label="显示 ID(缩放 >=15 级时显示)"
/>
<FormControlLabel
control={
<Checkbox
checked={styleConfig.showLabels}
onChange={(e) =>
setStyleConfig((prev) => ({ ...prev, showLabels: e.target.checked }))
}
/>
}
label="显示属性(缩放 >=15 级时显示)"
/>
<div className="my-3"></div>
<Box className="flex gap-2">
<Button
variant="contained"
color="primary"
onClick={onApply}
disabled={!selectedRenderLayer || !styleConfig.property}
startIcon={<ApplyIcon />}
fullWidth
>
</Button>
<Button
variant="outlined"
onClick={onReset}
disabled={!selectedRenderLayer}
startIcon={<ResetIcon />}
fullWidth
>
</Button>
</Box>
</div>
);
};
export default StyleEditorForm;
File diff suppressed because it is too large Load Diff
@@ -39,6 +39,23 @@ interface TimelineProps {
schemeType?: string;
}
const timelineIconButtonSx = {
width: 32,
height: 32,
borderRadius: "50%",
flexShrink: 0,
overflow: "hidden",
"&:hover": {
borderRadius: "50%",
},
"&.Mui-focusVisible": {
borderRadius: "50%",
},
"& .MuiTouchRipple-root": {
borderRadius: "50%",
},
} as const;
const NOOP_SET_CURRENT_TIME = (_: any) => undefined;
const NOOP_SET_SELECTED_DATE = (_: any) => undefined;
@@ -68,6 +85,7 @@ const Timeline: React.FC<TimelineProps> = ({
const isCompareMode = data?.isCompareMode ?? false;
const junctionText = data?.junctionText ?? "";
const pipeText = data?.pipeText ?? "";
const setForceStyleAutoApplyVersion = data?.setForceStyleAutoApplyVersion;
const { open } = useNotification();
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
@@ -605,7 +623,10 @@ const Timeline: React.FC<TimelineProps> = ({
// 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变
const calculationDate = selectedDate;
const calculationTime = currentTime;
const calculationDateStr = calculationDate.toISOString().split("T")[0];
const calculationDateTime = currentTimeToDate(
calculationDate,
calculationTime
);
setIsCalculating(true);
// 显示处理中的通知
@@ -617,8 +638,7 @@ const Timeline: React.FC<TimelineProps> = ({
try {
const body = {
name: NETWORK_NAME,
simulation_date: calculationDateStr, // YYYY-MM-DD
start_time: `${formatTime(calculationTime)}:00`, // HH:MM:00
start_time: dayjs(calculationDateTime).format("YYYY-MM-DDTHH:mm:ssZ"),
duration: calculatedInterval,
};
@@ -633,17 +653,22 @@ const Timeline: React.FC<TimelineProps> = ({
},
);
if (response.ok) {
const result = await response.json().catch(() => null);
if (response.ok && result?.status === "success") {
open?.({
type: "success",
message: "重新计算成功",
});
// 清空当天当前时刻及之后的缓存并重新获取数据
clearCacheAndRefetch(calculationDate, calculationTime);
setForceStyleAutoApplyVersion?.((prev) => prev + 1);
} else {
const errorMessage =
result?.detail || result?.message || "重新计算失败";
open?.({
type: "error",
message: "重新计算失败",
message: errorMessage,
});
}
} catch (error) {
@@ -665,7 +690,7 @@ const Timeline: React.FC<TimelineProps> = ({
<Draggable nodeRef={draggableRef} handle=".drag-handle">
<div
ref={draggableRef}
className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[920px] opacity-90 hover:opacity-100 transition-opacity duration-300"
className="absolute bottom-4 left-1/2 z-10 w-[950px] max-w-[calc(100vw-2rem)] -translate-x-1/2 opacity-90 transition-opacity duration-300 hover:opacity-100"
>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<Paper
@@ -723,6 +748,7 @@ const Timeline: React.FC<TimelineProps> = ({
onClick={handleDayStepBackward}
size="small"
disabled={disableDateSelection}
sx={timelineIconButtonSx}
>
<FiSkipBack />
</IconButton>
@@ -757,6 +783,7 @@ const Timeline: React.FC<TimelineProps> = ({
selectedDate.toDateString() ===
new Date().toDateString()
}
sx={timelineIconButtonSx}
>
<FiSkipForward />
</IconButton>
@@ -785,6 +812,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="primary"
onClick={handleStepBackward}
size="small"
sx={timelineIconButtonSx}
>
<TbRewindBackward15 />
</IconButton>
@@ -795,6 +823,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="primary"
onClick={isPlaying ? handlePause : handlePlay}
size="small"
sx={timelineIconButtonSx}
>
{isPlaying ? <Pause /> : <PlayArrow />}
</IconButton>
@@ -805,6 +834,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="primary"
onClick={handleStepForward}
size="small"
sx={timelineIconButtonSx}
>
<TbRewindForward15 />
</IconButton>
@@ -815,6 +845,7 @@ const Timeline: React.FC<TimelineProps> = ({
color="secondary"
onClick={handleStop}
size="small"
sx={timelineIconButtonSx}
>
<Stop />
</IconButton>
+30 -80
View File
@@ -14,7 +14,8 @@ import VectorLayer from "ol/layer/Vector";
import { Style, Stroke, Fill, Circle } from "ol/style";
import Feature from "ol/Feature";
import StyleEditorPanel from "./StyleEditorPanel";
import { LayerStyleState } from "./StyleEditorPanel";
import { createDefaultLayerStyleStates } from "./styleEditorPresets";
import { LayerStyleState } from "./styleEditorTypes";
import StyleLegend from "./StyleLegend"; // 引入图例组件
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
import { useNotification } from "@refinedev/core";
@@ -23,6 +24,7 @@ import {
buildFeatureProperties,
} from "./toolbarFeatureHelpers";
import { useToolbarChatActions } from "./useToolbarChatActions";
import { useStyleEditor } from "./useStyleEditor";
import { config } from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
@@ -80,92 +82,27 @@ const Toolbar: React.FC<ToolbarProps> = ({
endTime?: string;
} | null>(null);
// 样式状态管理 - 在 Toolbar 中管理,带有默认样式
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>(
() => createDefaultLayerStyleStates()
);
const styleEditor = useStyleEditor({
layerStyleStates,
setLayerStyleStates,
});
useToolbarChatActions({
setHighlightFeatures,
setChatPanelFeatureInfos,
setChatPanelType,
setChatPanelTimeRange,
setShowHistoryPanel,
setShowStyleEditor,
setActiveTools,
applyExternalStyle: styleEditor.applyExternalStyle,
resetExternalStyle: styleEditor.resetExternalStyle,
});
// 样式状态管理 - 在 Toolbar 中管理,带有默认样式
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([
{
isActive: false, // 默认不激活,不显示图例
layerId: "junctions",
layerName: "节点",
styleConfig: {
property: "pressure",
classificationMethod: "custom_breaks",
customBreaks: [16, 18, 20, 22, 24, 26],
customColors: [
"rgba(255, 0, 0, 1)",
"rgba(255, 127, 0, 1)",
"rgba(255, 215, 0, 1)",
"rgba(199, 224, 0, 1)",
"rgba(76, 175, 80, 1)",
"rgba(0, 158, 115, 1)",
],
segments: 6,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 8,
fixedStrokeWidth: 3,
colorType: "rainbow",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: false,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
},
legendConfig: {
layerId: "junctions",
layerName: "节点",
property: "压力", // 暂时为空,等计算后更新
colors: [],
type: "point",
dimensions: [],
breaks: [],
},
},
{
isActive: false, // 默认不激活,不显示图例
layerId: "pipes",
layerName: "管道",
styleConfig: {
property: "flow",
classificationMethod: "pretty_breaks",
segments: 6,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 8,
fixedStrokeWidth: 3,
colorType: "gradient",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: false,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
},
legendConfig: {
layerId: "pipes",
layerName: "管道",
property: "流量", // 暂时为空,等计算后更新
colors: [],
type: "linestring",
dimensions: [],
breaks: [],
},
},
]);
// 计算激活的图例配置
const activeLegendConfigs = layerStyleStates
.filter((state) => state.isActive && state.legendConfig.property)
@@ -515,8 +452,21 @@ const Toolbar: React.FC<ToolbarProps> = ({
{showDrawPanel && map && <DrawPanel />}
<div style={{ display: showStyleEditor ? "block" : "none" }}>
<StyleEditorPanel
layerStyleStates={layerStyleStates}
setLayerStyleStates={setLayerStyleStates}
isReady={styleEditor.isReady}
renderLayers={styleEditor.renderLayers}
selectedRenderLayer={styleEditor.selectedRenderLayer}
styleConfig={styleEditor.styleConfig}
setStyleConfig={styleEditor.setStyleConfig}
availableProperties={styleEditor.availableProperties}
onLayerChange={styleEditor.handleLayerChange}
onPropertyChange={styleEditor.handlePropertyChange}
onClassificationMethodChange={styleEditor.handleClassificationMethodChange}
onSegmentsChange={styleEditor.handleSegmentsChange}
onCustomBreakChange={styleEditor.handleCustomBreakChange}
onCustomBreakBlur={styleEditor.handleCustomBreakBlur}
onColorTypeChange={styleEditor.handleColorTypeChange}
onApply={styleEditor.handleApply}
onReset={styleEditor.handleReset}
/>
</div>
<ToolbarHistoryPanel
@@ -0,0 +1,200 @@
import { LayerStyleState, StyleConfig, DefaultLayerStyleId } from "./styleEditorTypes";
export const SINGLE_COLOR_PALETTES = [
{ color: "rgba(51, 153, 204, 1)" },
{ color: "rgba(255, 138, 92, 1)" },
{ color: "rgba(204, 51, 51, 1)" },
{ color: "rgba(255, 235, 59, 1)" },
{ color: "rgba(44, 160, 44, 1)" },
{ color: "rgba(227, 119, 194, 1)" },
{ color: "rgba(148, 103, 189, 1)" },
];
export const GRADIENT_PALETTES = [
{
name: "蓝-红",
start: "rgba(51, 153, 204, 1)",
end: "rgba(204, 51, 51, 1)",
},
{
name: "黄-绿",
start: "rgba(255, 235, 59, 1)",
end: "rgba(44, 160, 44, 1)",
},
{
name: "粉-紫",
start: "rgba(227, 119, 194, 1)",
end: "rgba(148, 103, 189, 1)",
},
];
export const RAINBOW_PALETTES = [
{
name: "正向彩虹",
colors: [
"rgba(255, 0, 0, 1)",
"rgba(255, 127, 0, 1)",
"rgba(255, 215, 0, 1)",
"rgba(199, 224, 0, 1)",
"rgba(76, 175, 80, 1)",
"rgba(0, 158, 115, 1)",
"rgba(0, 188, 212, 1)",
"rgba(33, 150, 243, 1)",
"rgba(63, 81, 181, 1)",
"rgba(142, 68, 173, 1)",
],
},
{
name: "反向彩虹",
colors: [
"rgba(142, 68, 173, 1)",
"rgba(63, 81, 181, 1)",
"rgba(33, 150, 243, 1)",
"rgba(0, 188, 212, 1)",
"rgba(0, 158, 115, 1)",
"rgba(76, 175, 80, 1)",
"rgba(199, 224, 0, 1)",
"rgba(255, 215, 0, 1)",
"rgba(255, 127, 0, 1)",
"rgba(255, 0, 0, 1)",
],
},
];
export const CLASSIFICATION_METHODS = [
{ name: "优雅分段", value: "pretty_breaks" },
{ name: "自定义", value: "custom_breaks" },
];
export const COLOR_TYPE_OPTIONS = [
{ label: "单一色", value: "single" },
{ label: "渐进色", value: "gradient" },
{ label: "离散彩虹", value: "rainbow" },
{ label: "自定义", value: "custom" },
];
const DEFAULT_LAYER_STYLE_PRESETS: Record<
DefaultLayerStyleId,
Omit<LayerStyleState, "isActive">
> = {
junctions: {
layerId: "junctions",
layerName: "节点",
styleConfig: {
property: "pressure",
classificationMethod: "custom_breaks",
customBreaks: [16, 18, 20, 22, 24, 26],
customColors: [
"rgba(255, 0, 0, 1)",
"rgba(255, 127, 0, 1)",
"rgba(255, 215, 0, 1)",
"rgba(199, 224, 0, 1)",
"rgba(76, 175, 80, 1)",
"rgba(0, 158, 115, 1)",
],
segments: 6,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 8,
fixedStrokeWidth: 3,
colorType: "rainbow",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: true,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
},
legendConfig: {
layerId: "junctions",
layerName: "节点",
property: "压力",
colors: [],
type: "point",
dimensions: [],
breaks: [],
},
},
pipes: {
layerId: "pipes",
layerName: "管道",
styleConfig: {
property: "velocity",
classificationMethod: "custom_breaks",
segments: 6,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 8,
fixedStrokeWidth: 3,
colorType: "gradient",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: true,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
customBreaks: [0.2, 0.4, 0.6, 0.8, 1.0, 1.2],
customColors: [],
},
legendConfig: {
layerId: "pipes",
layerName: "管道",
property: "流速",
colors: [],
type: "linestring",
dimensions: [],
breaks: [],
},
},
};
export const createEmptyStyleConfig = (): StyleConfig => ({
property: "",
classificationMethod: "pretty_breaks",
segments: 5,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 6,
fixedStrokeWidth: 3,
colorType: "single",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: false,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
customBreaks: [],
customColors: [],
});
export const createDefaultLayerStyleState = (
layerId: DefaultLayerStyleId
): LayerStyleState => {
const preset = DEFAULT_LAYER_STYLE_PRESETS[layerId];
return {
...preset,
styleConfig: {
...preset.styleConfig,
customBreaks: [...(preset.styleConfig.customBreaks || [])],
customColors: [...(preset.styleConfig.customColors || [])],
},
legendConfig: {
...preset.legendConfig,
colors: [...preset.legendConfig.colors],
dimensions: [...preset.legendConfig.dimensions],
breaks: [...preset.legendConfig.breaks],
},
isActive: false,
};
};
export const createDefaultLayerStyleStates = (): LayerStyleState[] => [
createDefaultLayerStyleState("junctions"),
createDefaultLayerStyleState("pipes"),
];
@@ -0,0 +1,66 @@
import React from "react";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import { LegendStyleConfig } from "./StyleLegend";
export interface StyleConfig {
property: string;
classificationMethod: string;
segments: number;
minSize: number;
maxSize: number;
minStrokeWidth: number;
maxStrokeWidth: number;
fixedStrokeWidth: number;
colorType: string;
singlePaletteIndex: number;
gradientPaletteIndex: number;
rainbowPaletteIndex: number;
showLabels: boolean;
showId: boolean;
opacity: number;
adjustWidthByProperty: boolean;
customBreaks?: number[];
customColors?: string[];
}
export interface LayerStyleState {
layerId: string;
layerName: string;
styleConfig: StyleConfig;
legendConfig: LegendStyleConfig;
isActive: boolean;
}
export type DefaultLayerStyleId = "junctions" | "pipes";
export interface StyleEditorStateProps {
layerStyleStates: LayerStyleState[];
setLayerStyleStates: React.Dispatch<React.SetStateAction<LayerStyleState[]>>;
}
export interface AvailableProperty {
name: string;
value: string;
}
export interface StyleEditorFormProps {
renderLayers: WebGLVectorTileLayer[];
selectedRenderLayer?: WebGLVectorTileLayer;
styleConfig: StyleConfig;
setStyleConfig: React.Dispatch<React.SetStateAction<StyleConfig>>;
availableProperties: AvailableProperty[];
onLayerChange: (index: number) => void;
onPropertyChange: (property: string) => void;
onClassificationMethodChange: (method: string) => void;
onSegmentsChange: (segments: number) => void;
onCustomBreakChange: (index: number, value: string) => void;
onCustomBreakBlur: () => void;
onColorTypeChange: (colorType: string) => void;
onApply: () => void;
onReset: () => void;
}
export interface StyleEditorPanelProps extends StyleEditorFormProps {
isReady: boolean;
}
@@ -0,0 +1,348 @@
import { FlatStyleLike } from "ol/style/flat";
import { calculateClassification } from "@utils/breaks_classification";
import { parseColor } from "@utils/parseColor";
import {
GRADIENT_PALETTES,
RAINBOW_PALETTES,
SINGLE_COLOR_PALETTES,
} from "./styleEditorPresets";
import { StyleConfig } from "./styleEditorTypes";
export const rgbaToHex = (rgba: string) => {
try {
const c = parseColor(rgba);
const toHex = (n: number) => {
const hex = Math.round(n).toString(16);
return hex.length === 1 ? `0${hex}` : hex;
};
return `#${toHex(c.r)}${toHex(c.g)}${toHex(c.b)}`;
} catch {
return "#000000";
}
};
export const hexToRgba = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(
result[3],
16
)}, 1)`
: "rgba(0, 0, 0, 1)";
};
export const getDefaultCustomColors = (
segments: number,
existingColors: string[] = []
) => {
const nextColors = [...existingColors];
const baseColors = RAINBOW_PALETTES[0].colors;
while (nextColors.length < segments) {
nextColors.push(baseColors[nextColors.length % baseColors.length]);
}
return nextColors.slice(0, segments);
};
export const getDefaultCustomBreaks = ({
segments,
property,
layerId,
elevationRange,
diameterRange,
currentJunctionCalData,
currentPipeCalData,
}: {
segments: number;
property: string;
layerId?: string;
elevationRange?: [number, number];
diameterRange?: [number, number];
currentJunctionCalData?: any[];
currentPipeCalData?: any[];
}) => {
if (!layerId || !property) {
return Array.from({ length: segments }, () => 0);
}
let dataArr: number[] = [];
const isElevation = layerId === "junctions" && property === "elevation";
const isDiameter = layerId === "pipes" && property === "diameter";
if (isElevation && elevationRange) {
dataArr = [elevationRange[0], elevationRange[1]];
} else if (isDiameter && diameterRange) {
dataArr = [diameterRange[0], diameterRange[1]];
} else if (layerId === "junctions" && currentJunctionCalData) {
dataArr = currentJunctionCalData.map((d: any) => d.value);
} else if (layerId === "pipes" && currentPipeCalData) {
dataArr = currentPipeCalData.map((d: any) => d.value);
}
if (dataArr.length === 0) {
return Array.from({ length: segments }, () => 0);
}
const defaultBreaks = calculateClassification(
dataArr,
segments,
"pretty_breaks"
).slice(0, segments);
while (defaultBreaks.length < segments) {
defaultBreaks.push(defaultBreaks[defaultBreaks.length - 1] ?? 0);
}
return defaultBreaks;
};
export const normalizeCustomBreaks = (breaks: number[], desired: number) => {
const nextBreaks = [...breaks]
.slice(0, desired)
.filter((value) => value >= 0)
.sort((a, b) => a - b);
while (nextBreaks.length < desired) {
nextBreaks.push(nextBreaks[nextBreaks.length - 1] ?? 0);
}
return nextBreaks;
};
export const addBreakExtrema = (breaks: number[], dataValues: number[]) => {
const nextBreaks = [...breaks];
const minValue = Math.max(
dataValues.reduce((min, value) => Math.min(min, value), Infinity),
0
);
const maxValue = dataValues.reduce(
(max, value) => Math.max(max, value),
-Infinity
);
if (!nextBreaks.includes(minValue)) {
nextBreaks.push(minValue);
}
if (!nextBreaks.includes(maxValue)) {
nextBreaks.push(maxValue);
}
nextBreaks.sort((a, b) => a - b);
return nextBreaks;
};
export const resolveStyleColors = (
styleConfig: StyleConfig,
breaksLength: number
): string[] => {
if (styleConfig.colorType === "single") {
return Array.from(
{ length: breaksLength },
() => SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color
);
}
if (styleConfig.colorType === "gradient") {
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
const startColor = parseColor(start);
const endColor = parseColor(end);
return Array.from({ length: breaksLength }, (_, index) => {
const ratio = breaksLength > 1 ? index / (breaksLength - 1) : 1;
const r = Math.round(startColor.r + (endColor.r - startColor.r) * ratio);
const g = Math.round(startColor.g + (endColor.g - startColor.g) * ratio);
const b = Math.round(startColor.b + (endColor.b - startColor.b) * ratio);
return `rgba(${r}, ${g}, ${b}, 1)`;
});
}
if (styleConfig.colorType === "rainbow") {
const baseColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
return Array.from(
{ length: breaksLength },
(_, index) => baseColors[index % baseColors.length]
);
}
const customColors = styleConfig.customColors || [];
const reverseRainbowColors = RAINBOW_PALETTES[1].colors;
const result = [...customColors];
while (result.length < breaksLength) {
result.push(
reverseRainbowColors[
(result.length - customColors.length) % reverseRainbowColors.length
]
);
}
return result.slice(0, breaksLength);
};
export const getSizePreviewColors = (styleConfig: StyleConfig) => {
if (styleConfig.colorType === "single") {
const color = SINGLE_COLOR_PALETTES[styleConfig.singlePaletteIndex].color;
return [color, color];
}
if (styleConfig.colorType === "gradient") {
const { start, end } = GRADIENT_PALETTES[styleConfig.gradientPaletteIndex];
return [start, end];
}
if (styleConfig.colorType === "rainbow") {
const rainbowColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
return [rainbowColors[0], rainbowColors[rainbowColors.length - 1]];
}
const customColors = styleConfig.customColors || [];
return [
customColors[0] || "rgba(0,0,0,1)",
customColors[customColors.length - 1] || "rgba(0,0,0,1)",
];
};
export const resolveDimensions = ({
layerType,
styleConfig,
breaksLength,
}: {
layerType: string;
styleConfig: StyleConfig;
breaksLength: number;
}) => {
if (layerType === "linestring") {
if (styleConfig.adjustWidthByProperty) {
return Array.from({ length: breaksLength }, (_, index) => {
const ratio = index / (breaksLength - 1);
return (
styleConfig.minStrokeWidth +
(styleConfig.maxStrokeWidth - styleConfig.minStrokeWidth) * ratio
);
});
}
return Array.from(
{ length: breaksLength },
() => styleConfig.fixedStrokeWidth
);
}
return Array.from({ length: breaksLength }, (_, index) => {
const ratio = index / (breaksLength - 1);
return styleConfig.minSize + (styleConfig.maxSize - styleConfig.minSize) * ratio;
});
};
export const buildDynamicStyle = ({
layerType,
styleConfig,
breaks,
colors,
dimensions,
}: {
layerType: string;
styleConfig: StyleConfig;
breaks: number[];
colors: string[];
dimensions: number[];
}): FlatStyleLike => {
const generateColorConditions = (property: string): any[] => {
const conditions: any[] = ["case"];
for (let index = 1; index < breaks.length; index++) {
if (property === "unit_headloss") {
conditions.push([
"<=",
["/", ["get", "unit_headloss"], ["/", ["get", "length"], 1000]],
breaks[index],
]);
} else {
conditions.push(["<=", ["get", property], breaks[index]]);
}
const colorObj = parseColor(colors[index - 1]);
conditions.push(
`rgba(${colorObj.r}, ${colorObj.g}, ${colorObj.b}, ${styleConfig.opacity})`
);
}
const defaultColor = parseColor(colors[0]);
conditions.push(
`rgba(${defaultColor.r}, ${defaultColor.g}, ${defaultColor.b}, ${styleConfig.opacity})`
);
return conditions;
};
const generateDimensionConditions = (property: string): any[] => {
const conditions: any[] = ["case"];
for (let index = 0; index < breaks.length; index++) {
if (property === "unit_headloss") {
conditions.push([
"<=",
["/", ["get", "headloss"], ["get", "length"]],
breaks[index],
]);
} else {
conditions.push(["<=", ["get", property], breaks[index]]);
}
conditions.push(dimensions[index]);
}
conditions.push(dimensions[dimensions.length - 1]);
return conditions;
};
const generatePointDimensionConditions = (property: string): any[] => {
const conditions: any[] = ["case"];
for (let index = 0; index < breaks.length; index++) {
conditions.push(["<=", ["get", property], breaks[index]]);
conditions.push(["interpolate", ["linear"], ["zoom"], 12, 1, 24, dimensions[index]]);
}
conditions.push(dimensions[dimensions.length - 1]);
return conditions;
};
const dynamicStyle: FlatStyleLike = {};
if (layerType === "linestring") {
dynamicStyle["stroke-color"] = generateColorConditions(styleConfig.property);
dynamicStyle["stroke-width"] = generateDimensionConditions(styleConfig.property);
} else if (layerType === "point") {
dynamicStyle["circle-fill-color"] = generateColorConditions(styleConfig.property);
dynamicStyle["circle-radius"] = generatePointDimensionConditions(
styleConfig.property
);
dynamicStyle["circle-stroke-color"] = generateColorConditions(styleConfig.property);
dynamicStyle["circle-stroke-width"] = 2;
}
return dynamicStyle;
};
export const buildContourDefinitions = ({
styleConfig,
breaks,
colors,
}: {
styleConfig: StyleConfig;
breaks: number[];
colors: string[];
}) => {
const contours = [];
for (let index = 0; index < breaks.length - 1; index++) {
const colorObj = parseColor(colors[index]);
contours.push({
threshold: [breaks[index], breaks[index + 1]],
color: [
colorObj.r,
colorObj.g,
colorObj.b,
Math.round(styleConfig.opacity * 255),
],
strokeWidth: 0,
});
}
return contours;
};
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,7 @@ import { apiFetch } from "@/lib/apiFetch";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { config } from "@/config/config";
import { useMap } from "../MapComponent";
import type { DefaultLayerStyleId, StyleConfig } from "./styleEditorTypes";
type UseToolbarChatActionsParams = {
setHighlightFeatures: Dispatch<SetStateAction<Feature[]>>;
@@ -22,7 +23,13 @@ type UseToolbarChatActionsParams = {
SetStateAction<{ startTime?: string; endTime?: string } | null>
>;
setShowHistoryPanel: Dispatch<SetStateAction<boolean>>;
setShowStyleEditor: Dispatch<SetStateAction<boolean>>;
setActiveTools: Dispatch<SetStateAction<string[]>>;
applyExternalStyle: (
layerId: DefaultLayerStyleId,
styleConfig?: Partial<StyleConfig>
) => void;
resetExternalStyle: (layerId: DefaultLayerStyleId) => void;
};
export const useToolbarChatActions = ({
@@ -31,7 +38,10 @@ export const useToolbarChatActions = ({
setChatPanelType,
setChatPanelTimeRange,
setShowHistoryPanel,
setShowStyleEditor,
setActiveTools,
applyExternalStyle,
resetExternalStyle,
}: UseToolbarChatActionsParams) => {
const map = useMap();
const chatJunctionRenderCleanupRef = useRef<(() => void) | null>(null);
@@ -206,17 +216,30 @@ export const useToolbarChatActions = ({
})();
break;
}
case "apply_layer_style": {
setShowStyleEditor(true);
setActiveTools((prev) => (prev.includes("style") ? prev : [...prev, "style"]));
if (action.resetToDefault) {
resetExternalStyle(action.layerId);
} else {
applyExternalStyle(action.layerId, action.styleConfig);
}
break;
}
}
},
[
applyExternalStyle,
disposeChatJunctionRender,
map,
resetExternalStyle,
setActiveTools,
setChatPanelFeatureInfos,
setChatPanelTimeRange,
setChatPanelType,
setHighlightFeatures,
setShowHistoryPanel,
setShowStyleEditor,
],
),
);
+11 -1
View File
@@ -65,8 +65,10 @@ interface DataContextType {
setShowPipeTextLayer?: React.Dispatch<React.SetStateAction<boolean>>;
setShowJunctionId?: React.Dispatch<React.SetStateAction<boolean>>;
setShowPipeId?: React.Dispatch<React.SetStateAction<boolean>>;
showContourLayer?: boolean;
setShowContourLayer?: React.Dispatch<React.SetStateAction<boolean>>;
isContourLayerAvailable?: boolean;
showWaterflowLayer?: boolean;
setShowWaterflowLayer?: React.Dispatch<React.SetStateAction<boolean>>;
setContourLayerAvailable?: React.Dispatch<React.SetStateAction<boolean>>;
isWaterflowLayerAvailable?: boolean;
@@ -83,6 +85,8 @@ interface DataContextType {
maps?: OlMap[];
diameterRange?: [number, number];
elevationRange?: [number, number];
forceStyleAutoApplyVersion?: number;
setForceStyleAutoApplyVersion?: React.Dispatch<React.SetStateAction<number>>;
}
// 跨组件传递
@@ -182,7 +186,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const [showPipeId, setShowPipeId] = useState(false); // 控制管道ID显示
const [showContourLayer, setShowContourLayer] = useState(false); // 控制等高线图层显示
const [junctionText, setJunctionText] = useState("pressure");
const [pipeText, setPipeText] = useState("flow");
const [pipeText, setPipeText] = useState("velocity");
const [contours, setContours] = useState<any[]>([]);
const flowAnimation = useRef(false); // 添加动画控制标志
const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示
@@ -261,6 +265,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const [elevationRange, setElevationRange] = useState<
[number, number] | undefined
>();
const [forceStyleAutoApplyVersion, setForceStyleAutoApplyVersion] =
useState(0);
const toggleCompareMode = useCallback(() => {
setCompareMode((prev) => !prev);
@@ -1504,8 +1510,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
setShowPipeId,
showJunctionId,
showPipeId,
showContourLayer,
setShowContourLayer,
isContourLayerAvailable,
showWaterflowLayer,
setContourLayerAvailable,
isWaterflowLayerAvailable,
setWaterflowLayerAvailable,
@@ -1522,6 +1530,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
maps,
diameterRange,
elevationRange,
forceStyleAutoApplyVersion,
setForceStyleAutoApplyVersion,
}}
>
<MapContext.Provider value={map}>
+217 -9
View File
@@ -1,4 +1,13 @@
import { abortAgentChat, forkAgentChat, streamAgentChat } from "./chatStream";
import {
abortAgentChat,
forkAgentChat,
rejectAgentQuestion,
replyAgentPermission,
replyAgentQuestion,
type StreamEvent,
resumeAgentChatStream,
streamAgentChat,
} from "./chatStream";
import { ReadableStream } from "stream/web";
import { TextEncoder, TextDecoder } from "util";
@@ -65,6 +74,7 @@ describe("streamAgentChat", () => {
message: "hi",
session_id: undefined,
model: "deepseek/deepseek-v4-pro",
approval_mode: undefined,
}),
}),
);
@@ -76,6 +86,51 @@ describe("streamAgentChat", () => {
]);
});
it("parses state events from a resumed stream", async () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: state\ndata: {"session_id":"s1","messages":[{"id":"a1","role":"assistant","content":"已输出"}],"is_streaming":true,"run_status":"running"}\n\n',
'event: token\ndata: {"session_id":"s1","content":"继续"}\n\n',
'event: done\ndata: {"session_id":"s1"}\n\n',
]),
});
const events: Array<{
type: string;
sessionId?: string;
messages?: unknown[];
isStreaming?: boolean;
runStatus?: string;
content?: string;
}> = [];
await resumeAgentChatStream({
sessionId: "s1",
onEvent: (event) => events.push(event),
});
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/session/s1/stream"),
expect.objectContaining({
method: "GET",
projectHeaderMode: "include",
skipAuthRedirect: true,
}),
);
expect(events).toEqual([
{
type: "state",
sessionId: "s1",
messages: [{ id: "a1", role: "assistant", content: "已输出" }],
isStreaming: true,
runStatus: "running",
},
{ type: "token", sessionId: "s1", content: "继续" },
{ type: "done", sessionId: "s1" },
]);
});
it("parses progress events", async () => {
apiFetch.mockResolvedValue({
ok: true,
@@ -103,21 +158,16 @@ describe("streamAgentChat", () => {
});
});
it("parses legacy tool_call arguments when params is empty", async () => {
it("parses tool_call arguments when params is empty", async () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: tool_call\ndata: {"conversationId":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n',
'event: tool_call\ndata: {"session_id":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n',
'event: done\ndata: {"session_id":"agent-1e75dd01-29e"}\n\n',
]),
});
const events: Array<{
type: string;
sessionId?: string;
tool?: string;
params?: Record<string, unknown>;
}> = [];
const events: StreamEvent[] = [];
await streamAgentChat({
message: "hi",
@@ -132,6 +182,106 @@ describe("streamAgentChat", () => {
});
});
it("parses permission request and response events", async () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: permission_request\ndata: {"session_id":"s1","request_id":"perm-1","permission":"bash","patterns":["rm *"],"target":"rm tmp.txt","always":["rm *"],"created_at":123}\n\n',
'event: permission_response\ndata: {"session_id":"s1","request_id":"perm-1","reply":"reject"}\n\n',
]),
});
const events: StreamEvent[] = [];
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
expect(events).toEqual([
{
type: "permission_request",
sessionId: "s1",
requestId: "perm-1",
permission: "bash",
patterns: ["rm *"],
target: "rm tmp.txt",
always: ["rm *"],
tool: undefined,
createdAt: 123,
},
{
type: "permission_response",
sessionId: "s1",
requestId: "perm-1",
reply: "reject",
},
]);
});
it("parses question request, response, and todo update events", async () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: question_request\ndata: {"session_id":"s1","request_id":"q-1","questions":[{"header":"范围","question":"选择范围","options":[{"label":"城区","description":"中心城区"}],"multiple":false,"custom":true}],"tool":{"message_id":"m1","call_id":"c1"},"created_at":123}\n\n',
'event: question_response\ndata: {"session_id":"s1","request_id":"q-1","answers":[["城区","补充说明"]]}\n\n',
'event: todo_update\ndata: {"session_id":"s1","todos":[{"id":"t1","content":"分析水位","status":"in_progress","priority":"high","updated_at":456}],"created_at":456}\n\n',
]),
});
const events: StreamEvent[] = [];
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
expect(events).toEqual([
{
type: "question_request",
sessionId: "s1",
requestId: "q-1",
questions: [
{
header: "范围",
question: "选择范围",
options: [{ label: "城区", description: "中心城区" }],
multiple: false,
custom: true,
},
],
tool: {
messageID: "m1",
callID: "c1",
},
createdAt: 123,
},
{
type: "question_response",
sessionId: "s1",
requestId: "q-1",
answers: [["城区", "补充说明"]],
rejected: false,
},
{
type: "todo_update",
sessionId: "s1",
messageId: undefined,
todos: [
{
id: "t1",
content: "分析水位",
status: "in_progress",
priority: "high",
createdAt: undefined,
updatedAt: 456,
},
],
createdAt: 456,
},
]);
});
it("emits error when response is not ok", async () => {
apiFetch.mockResolvedValue({
ok: false,
@@ -205,6 +355,64 @@ describe("streamAgentChat", () => {
);
});
it("calls permission reply endpoint", async () => {
apiFetch.mockResolvedValue({
ok: true,
status: 202,
text: async () => "",
});
await replyAgentPermission("s1", "perm-1", "once");
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/permission/perm-1/reply"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
session_id: "s1",
reply: "once",
}),
}),
);
});
it("calls question reply and reject endpoints", async () => {
apiFetch.mockResolvedValue({
ok: true,
status: 202,
text: async () => "",
});
await replyAgentQuestion("s1", "q-1", [["城区"]]);
await rejectAgentQuestion("s1", "q-2");
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/question/q-1/reply"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
session_id: "s1",
answers: [["城区"]],
}),
}),
);
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/question/q-2/reject"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
session_id: "s1",
}),
}),
);
});
it("calls fork endpoint and returns new session id", async () => {
apiFetch.mockResolvedValue({
ok: true,
+495 -91
View File
@@ -5,7 +5,64 @@ export type AgentModel =
| "deepseek/deepseek-v4-flash"
| "deepseek/deepseek-v4-pro";
export type PermissionReply = "once" | "always" | "reject";
export type AgentApprovalMode = "request" | "always";
export type AgentQuestionStatus =
| "pending"
| "submitting"
| "answered"
| "rejected"
| "error";
export type AgentQuestionRequest = {
requestId: string;
sessionId: string;
questions: Array<{
header: string;
question: string;
options: Array<{
label: string;
description: string;
}>;
multiple?: boolean;
custom?: boolean;
}>;
tool?: {
messageID: string;
callID: string;
};
createdAt: number;
repliedAt?: number;
status: AgentQuestionStatus;
answers?: string[][];
error?: string;
};
export type AgentTodoItem = {
id: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
priority?: "low" | "medium" | "high";
createdAt?: number;
updatedAt?: number;
};
export type AgentTodoUpdate = {
sessionId: string;
messageId?: string;
todos: AgentTodoItem[];
createdAt: number;
};
export type StreamEvent =
| {
type: "state";
sessionId: string;
messages: unknown[];
isStreaming: boolean;
runStatus?: string;
}
| { type: "token"; sessionId: string; content: string }
| { type: "done"; sessionId: string; totalDurationMs?: number }
| { type: "session_title"; sessionId: string; title: string }
@@ -34,12 +91,61 @@ export type StreamEvent =
sessionId: string;
tool: string;
params: Record<string, unknown>;
}
| {
type: "permission_request";
sessionId: string;
requestId: string;
permission: string;
patterns: string[];
target?: string;
always: string[];
tool?: {
messageID: string;
callID: string;
};
createdAt: number;
}
| {
type: "permission_response";
sessionId: string;
requestId: string;
reply: PermissionReply;
}
| {
type: "question_request";
sessionId: string;
requestId: string;
questions: AgentQuestionRequest["questions"];
tool?: AgentQuestionRequest["tool"];
createdAt: number;
}
| {
type: "question_response";
sessionId: string;
requestId: string;
answers?: string[][];
rejected?: boolean;
}
| {
type: "todo_update";
sessionId: string;
messageId?: string;
todos: AgentTodoItem[];
createdAt: number;
};
type StreamOptions = {
message: string;
sessionId?: string;
model?: AgentModel;
approvalMode?: AgentApprovalMode;
signal?: AbortSignal;
onEvent: (event: StreamEvent) => void;
};
type ResumeStreamOptions = {
sessionId: string;
signal?: AbortSignal;
onEvent: (event: StreamEvent) => void;
};
@@ -87,10 +193,272 @@ const resolveToolParams = (
return isObjectRecord(params) ? params : {};
};
const normalizeQuestionList = (value: unknown): AgentQuestionRequest["questions"] => {
if (!Array.isArray(value)) return [];
return value
.filter(isObjectRecord)
.map((question) => ({
header: typeof question.header === "string" ? question.header : "",
question: typeof question.question === "string" ? question.question : "",
options: Array.isArray(question.options)
? question.options.filter(isObjectRecord).map((option) => ({
label: typeof option.label === "string" ? option.label : "",
description:
typeof option.description === "string" ? option.description : "",
}))
: [],
multiple: typeof question.multiple === "boolean" ? question.multiple : undefined,
custom: typeof question.custom === "boolean" ? question.custom : undefined,
}));
};
const normalizeAnswers = (value: unknown): string[][] | undefined => {
if (!Array.isArray(value)) return undefined;
return value.map((answer) =>
Array.isArray(answer)
? answer.filter((item): item is string => typeof item === "string")
: [],
);
};
const normalizeQuestionTool = (value: unknown): AgentQuestionRequest["tool"] => {
if (!isObjectRecord(value)) return undefined;
const messageID =
typeof value.messageID === "string"
? value.messageID
: typeof value.message_id === "string"
? value.message_id
: undefined;
const callID =
typeof value.callID === "string"
? value.callID
: typeof value.call_id === "string"
? value.call_id
: undefined;
return messageID && callID ? { messageID, callID } : undefined;
};
const normalizeTodoStatus = (value: unknown): AgentTodoItem["status"] => {
if (value === "in_progress" || value === "completed" || value === "cancelled") {
return value;
}
return "pending";
};
const normalizeTodoPriority = (value: unknown): AgentTodoItem["priority"] => {
if (value === "low" || value === "medium" || value === "high") {
return value;
}
return undefined;
};
const normalizeTodos = (value: unknown): AgentTodoItem[] => {
if (!Array.isArray(value)) return [];
return value.filter(isObjectRecord).map((todo, index) => ({
id:
typeof todo.id === "string" && todo.id.trim()
? todo.id
: `todo-${index}`,
content: typeof todo.content === "string" ? todo.content : "",
status: normalizeTodoStatus(todo.status),
priority: normalizeTodoPriority(todo.priority),
createdAt: typeof todo.created_at === "number" ? todo.created_at : undefined,
updatedAt: typeof todo.updated_at === "number" ? todo.updated_at : undefined,
}));
};
const emitParsedStreamEvent = (
event: string,
data: string,
onEvent: (event: StreamEvent) => void,
) => {
try {
const parsed = JSON.parse(data) as {
session_id?: string;
content?: string;
message?: string;
detail?: string;
tool?: unknown;
params?: Record<string, unknown>;
arguments?: unknown;
id?: string;
phase?: string;
status?: "running" | "completed" | "error";
title?: string;
messages?: unknown[];
is_streaming?: boolean;
run_status?: string;
started_at?: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
total_duration_ms?: number;
request_id?: string;
permission?: string;
patterns?: unknown;
target?: string;
always?: unknown;
created_at?: number;
reply?: PermissionReply;
questions?: unknown;
answers?: unknown;
rejected?: boolean;
message_id?: string;
todos?: unknown;
};
if (event === "state") {
onEvent({
type: "state",
sessionId: parsed.session_id ?? "",
messages: Array.isArray(parsed.messages) ? parsed.messages : [],
isStreaming: parsed.is_streaming ?? false,
runStatus: parsed.run_status,
});
} else if (event === "token") {
onEvent({
type: "token",
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,
startedAt: parsed.started_at,
endedAt: parsed.ended_at,
elapsedMs: parsed.elapsed_ms,
durationMs: parsed.duration_ms,
});
} else if (event === "done") {
onEvent({
type: "done",
sessionId: parsed.session_id ?? "",
totalDurationMs: parsed.total_duration_ms,
});
} else if (event === "session_title") {
onEvent({
type: "session_title",
sessionId: parsed.session_id ?? "",
title: typeof parsed.title === "string" ? parsed.title : "",
});
} else if (event === "error") {
onEvent({
type: "error",
sessionId: parsed.session_id,
message: parsed.message ?? "unknown error",
detail: parsed.detail,
totalDurationMs: parsed.total_duration_ms,
});
} else if (event === "tool_call") {
onEvent({
type: "tool_call",
sessionId: parsed.session_id ?? "",
tool: typeof parsed.tool === "string" ? parsed.tool : "",
params: resolveToolParams(parsed.params, parsed.arguments),
});
} else if (event === "permission_request") {
onEvent({
type: "permission_request",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
permission: parsed.permission ?? "",
patterns: Array.isArray(parsed.patterns)
? parsed.patterns.filter((item): item is string => typeof item === "string")
: [],
target: typeof parsed.target === "string" ? parsed.target : undefined,
always: Array.isArray(parsed.always)
? parsed.always.filter((item): item is string => typeof item === "string")
: [],
tool: isObjectRecord(parsed.tool) &&
typeof parsed.tool.messageID === "string" &&
typeof parsed.tool.callID === "string"
? {
messageID: parsed.tool.messageID,
callID: parsed.tool.callID,
}
: undefined,
createdAt: parsed.created_at ?? Date.now(),
});
} else if (event === "permission_response") {
onEvent({
type: "permission_response",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
reply: parsed.reply ?? "reject",
});
} else if (event === "question_request") {
onEvent({
type: "question_request",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
questions: normalizeQuestionList(parsed.questions),
tool: normalizeQuestionTool(parsed.tool),
createdAt: parsed.created_at ?? Date.now(),
});
} else if (event === "question_response") {
onEvent({
type: "question_response",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
answers: normalizeAnswers(parsed.answers),
rejected: parsed.rejected === true,
});
} else if (event === "todo_update") {
onEvent({
type: "todo_update",
sessionId: parsed.session_id ?? "",
messageId: parsed.message_id,
todos: normalizeTodos(parsed.todos),
createdAt: parsed.created_at ?? Date.now(),
});
}
} catch {
onEvent({
type: "error",
message: "invalid SSE data payload",
detail: data,
});
}
};
const readStreamEvents = async (
response: Response,
onEvent: (event: StreamEvent) => void,
) => {
if (!response.body) {
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split("\n\n");
buffer = blocks.pop() ?? "";
for (const block of blocks) {
const { event, data } = parseEventBlock(block);
if (!event || !data) continue;
emitParsedStreamEvent(event, data, onEvent);
}
}
};
export const streamAgentChat = async ({
message,
sessionId,
model,
approvalMode,
signal,
onEvent,
}: StreamOptions) => {
@@ -109,6 +477,7 @@ export const streamAgentChat = async ({
message,
session_id: sessionId,
model,
approval_mode: approvalMode,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
@@ -144,99 +513,52 @@ export const streamAgentChat = async ({
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
await readStreamEvents(response, onEvent);
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split("\n\n");
buffer = blocks.pop() ?? "";
for (const block of blocks) {
const { event, data } = parseEventBlock(block);
if (!event || !data) continue;
try {
const parsed = JSON.parse(data) as {
session_id?: string;
conversationId?: string;
content?: string;
message?: string;
detail?: string;
tool?: string;
params?: Record<string, unknown>;
arguments?: unknown;
id?: string;
phase?: string;
status?: "running" | "completed" | "error";
title?: string;
started_at?: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
total_duration_ms?: number;
};
if (event === "token") {
onEvent({
type: "token",
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,
startedAt: parsed.started_at,
endedAt: parsed.ended_at,
elapsedMs: parsed.elapsed_ms,
durationMs: parsed.duration_ms,
});
} else if (event === "done") {
onEvent({
type: "done",
sessionId: parsed.session_id ?? "",
totalDurationMs: parsed.total_duration_ms,
});
} else if (event === "session_title") {
onEvent({
type: "session_title",
sessionId: parsed.session_id ?? "",
title: typeof parsed.title === "string" ? parsed.title : "",
});
} else if (event === "error") {
onEvent({
type: "error",
sessionId: parsed.session_id,
message: parsed.message ?? "unknown error",
detail: parsed.detail,
totalDurationMs: parsed.total_duration_ms,
});
} else if (event === "tool_call") {
onEvent({
type: "tool_call",
sessionId: parsed.session_id ?? parsed.conversationId ?? "",
tool: parsed.tool ?? "",
params: resolveToolParams(parsed.params, parsed.arguments),
});
}
} catch {
onEvent({
type: "error",
message: "invalid SSE data payload",
detail: data,
});
}
}
export const resumeAgentChatStream = async ({
sessionId,
signal,
onEvent,
}: ResumeStreamOptions) => {
let response: Response;
try {
response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/stream`,
{
method: "GET",
signal,
headers: {
Accept: "text/event-stream",
},
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
onEvent({
type: "error",
sessionId,
message: "network request failed",
detail,
});
return;
}
if (!response.ok || !response.body) {
const detail = await response.text();
onEvent({
type: "error",
sessionId,
message: "stream request failed",
detail,
});
return;
}
await readStreamEvents(response, onEvent);
};
export const abortAgentChat = async (sessionId?: string) => {
@@ -263,6 +585,88 @@ export const abortAgentChat = async (sessionId?: string) => {
}
};
export const replyAgentPermission = async (
sessionId: string,
requestId: string,
reply: PermissionReply,
) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/permission/${encodeURIComponent(requestId)}/reply`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
reply,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `permission reply failed: ${response.status}`);
}
};
export const replyAgentQuestion = async (
sessionId: string,
requestId: string,
answers: string[][],
) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reply`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
answers,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `question reply failed: ${response.status}`);
}
};
export const rejectAgentQuestion = async (
sessionId: string,
requestId: string,
) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reject`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `question reject failed: ${response.status}`);
}
};
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, {
method: "POST",
+8
View File
@@ -1,5 +1,7 @@
import { create } from "zustand";
import type { DefaultLayerStyleId, StyleConfig } from "@components/olmap/core/Controls/styleEditorTypes";
/* ------------------------------------------------------------------ */
/* Chat Tool Action Store */
/* Decouples chat tool calls from map/panel execution. */
@@ -39,6 +41,12 @@ export type ChatToolAction =
type: "render_junctions";
renderRef: string;
sessionId?: string;
}
| {
type: "apply_layer_style";
layerId: DefaultLayerStyleId;
resetToDefault: boolean;
styleConfig?: Partial<StyleConfig>;
};
interface ChatToolState {