2 Commits
Author SHA1 Message Date
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
6 changed files with 164 additions and 4 deletions
+98 -1
View File
@@ -26,7 +26,9 @@ import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import AttachFileRounded from "@mui/icons-material/AttachFileRounded"; import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
import BoltRounded from "@mui/icons-material/BoltRounded"; import BoltRounded from "@mui/icons-material/BoltRounded";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded"; 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 = { export type AgentComposerHandle = {
focus: () => void; focus: () => void;
@@ -48,6 +50,8 @@ type AgentComposerProps = {
onStopListening: () => void; onStopListening: () => void;
selectedModel: AgentModel; selectedModel: AgentModel;
onModelChange: (model: AgentModel) => void; onModelChange: (model: AgentModel) => void;
approvalMode: AgentApprovalMode;
onApprovalModeChange: (mode: AgentApprovalMode) => void;
}; };
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
@@ -62,6 +66,8 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
onStopListening, onStopListening,
selectedModel, selectedModel,
onModelChange, onModelChange,
approvalMode,
onApprovalModeChange,
}, ref) { }, ref) {
const theme = useTheme(); const theme = useTheme();
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null); const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
@@ -245,6 +251,97 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
</IconButton> </IconButton>
) )
) : null} ) : 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>
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
+6 -1
View File
@@ -10,7 +10,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { getAccessToken } from "@/lib/authToken"; import { getAccessToken } from "@/lib/authToken";
import type { AgentModel } from "@/lib/chatStream"; import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer"; import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
import { AgentHeader } from "./AgentHeader"; import { AgentHeader } from "./AgentHeader";
@@ -31,6 +31,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [selectedModel, setSelectedModel] = useState<AgentModel>( const [selectedModel, setSelectedModel] = useState<AgentModel>(
"deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-pro",
); );
const [approvalMode, setApprovalMode] =
useState<AgentApprovalMode>("request");
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const composerRef = useRef<AgentComposerHandle | null>(null); const composerRef = useRef<AgentComposerHandle | null>(null);
@@ -85,6 +87,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
onToolCall: handleToolCall, onToolCall: handleToolCall,
onBeforeSend: stopListening, onBeforeSend: stopListening,
getModel: () => selectedModel, getModel: () => selectedModel,
getApprovalMode: () => approvalMode,
}); });
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
@@ -371,6 +374,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
onStopListening={stopListening} onStopListening={stopListening}
selectedModel={selectedModel} selectedModel={selectedModel}
onModelChange={setSelectedModel} onModelChange={setSelectedModel}
approvalMode={approvalMode}
onApprovalModeChange={setApprovalMode}
/> />
</Box> </Box>
</Box> </Box>
@@ -521,4 +521,33 @@ describe("useAgentChatSession", () => {
expect.anything(), expect.anything(),
); );
}); });
it("asks the backend to undo the previous user turn before regenerating", 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("重新分析压力异常");
});
await act(async () => {
await result.current.regenerate();
});
expect(streamAgentChat).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
message: "重新分析压力异常",
regenerateFromMessageIndex: 0,
}),
);
});
}); });
@@ -9,7 +9,12 @@ import {
resumeAgentChatStream, resumeAgentChatStream,
streamAgentChat, streamAgentChat,
} from "@/lib/chatStream"; } from "@/lib/chatStream";
import type { AgentModel, PermissionReply, StreamEvent } from "@/lib/chatStream"; import type {
AgentApprovalMode,
AgentModel,
PermissionReply,
StreamEvent,
} from "@/lib/chatStream";
import type { import type {
AgentArtifact, AgentArtifact,
AgentPermissionRequest, AgentPermissionRequest,
@@ -45,11 +50,13 @@ type UseAgentChatSessionOptions = {
) => void; ) => void;
onBeforeSend?: () => void; onBeforeSend?: () => void;
getModel?: () => AgentModel; getModel?: () => AgentModel;
getApprovalMode?: () => AgentApprovalMode;
}; };
type PromptRunOptions = { type PromptRunOptions = {
prompt: string; prompt: string;
sessionIdOverride?: string; sessionIdOverride?: string;
regenerateFromMessageIndex?: number;
preparedMessages?: Message[]; preparedMessages?: Message[];
userMessage?: Message; userMessage?: Message;
assistantMessage?: Message; assistantMessage?: Message;
@@ -210,6 +217,7 @@ export const useAgentChatSession = ({
onToolCall, onToolCall,
onBeforeSend, onBeforeSend,
getModel, getModel,
getApprovalMode,
}: UseAgentChatSessionOptions) => { }: UseAgentChatSessionOptions) => {
const hydrationCompletedRef = useRef(false); const hydrationCompletedRef = useRef(false);
const hydrationNonceRef = useRef(0); const hydrationNonceRef = useRef(0);
@@ -602,6 +610,7 @@ export const useAgentChatSession = ({
async ({ async ({
prompt: rawPrompt, prompt: rawPrompt,
sessionIdOverride, sessionIdOverride,
regenerateFromMessageIndex,
preparedMessages, preparedMessages,
userMessage, userMessage,
assistantMessage, assistantMessage,
@@ -636,6 +645,8 @@ export const useAgentChatSession = ({
message: prompt, message: prompt,
sessionId: sessionIdOverride ?? sessionIdRef.current, sessionId: sessionIdOverride ?? sessionIdRef.current,
model: getModel?.(), model: getModel?.(),
approvalMode: getApprovalMode?.(),
regenerateFromMessageIndex,
signal: controller.signal, signal: controller.signal,
onEvent: (event) => onEvent: (event) =>
applyStreamEvent(event, { applyStreamEvent(event, {
@@ -686,7 +697,15 @@ export const useAgentChatSession = ({
setIsStreaming(false); setIsStreaming(false);
} }
}, },
[applyStreamEvent, getModel, isHydrating, isStreaming, messages, onBeforeSend], [
applyStreamEvent,
getApprovalMode,
getModel,
isHydrating,
isStreaming,
messages,
onBeforeSend,
],
); );
const abort = useCallback(() => { const abort = useCallback(() => {
@@ -975,6 +994,7 @@ export const useAgentChatSession = ({
setMessages(nextMessages); setMessages(nextMessages);
await runPrompt({ await runPrompt({
prompt: lastUserContent, prompt: lastUserContent,
regenerateFromMessageIndex: lastUserIndex,
preparedMessages: [ preparedMessages: [
...nextMessages, ...nextMessages,
nextUserMessage, nextUserMessage,
+2
View File
@@ -72,6 +72,8 @@ describe("streamAgentChat", () => {
message: "hi", message: "hi",
session_id: undefined, session_id: undefined,
model: "deepseek/deepseek-v4-pro", model: "deepseek/deepseek-v4-pro",
approval_mode: undefined,
regenerate_from_message_index: undefined,
}), }),
}), }),
); );
+7
View File
@@ -6,6 +6,7 @@ export type AgentModel =
| "deepseek/deepseek-v4-pro"; | "deepseek/deepseek-v4-pro";
export type PermissionReply = "once" | "always" | "reject"; export type PermissionReply = "once" | "always" | "reject";
export type AgentApprovalMode = "request" | "always";
export type StreamEvent = export type StreamEvent =
| { | {
@@ -69,6 +70,8 @@ type StreamOptions = {
message: string; message: string;
sessionId?: string; sessionId?: string;
model?: AgentModel; model?: AgentModel;
approvalMode?: AgentApprovalMode;
regenerateFromMessageIndex?: number;
signal?: AbortSignal; signal?: AbortSignal;
onEvent: (event: StreamEvent) => void; onEvent: (event: StreamEvent) => void;
}; };
@@ -283,6 +286,8 @@ export const streamAgentChat = async ({
message, message,
sessionId, sessionId,
model, model,
approvalMode,
regenerateFromMessageIndex,
signal, signal,
onEvent, onEvent,
}: StreamOptions) => { }: StreamOptions) => {
@@ -301,6 +306,8 @@ export const streamAgentChat = async ({
message, message,
session_id: sessionId, session_id: sessionId,
model, model,
approval_mode: approvalMode,
regenerate_from_message_index: regenerateFromMessageIndex,
}), }),
projectHeaderMode: "include", projectHeaderMode: "include",
userHeaderMode: "include", userHeaderMode: "include",