Compare commits
2 Commits
57369772c7
...
5fc1812d53
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fc1812d53 | |||
| 709b029c4e |
@@ -7,7 +7,9 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
||||||
|
import { useNotification } from "@refinedev/core";
|
||||||
|
|
||||||
|
import { getAccessToken } from "@/lib/authToken";
|
||||||
import type { AgentModel } from "@/lib/chatStream";
|
import type { 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";
|
||||||
@@ -25,6 +27,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
const [width, setWidth] = useState(520);
|
const [width, setWidth] = useState(520);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||||
|
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<AgentModel>(
|
const [selectedModel, setSelectedModel] = useState<AgentModel>(
|
||||||
"deepseek/deepseek-v4-pro",
|
"deepseek/deepseek-v4-pro",
|
||||||
);
|
);
|
||||||
@@ -33,6 +36,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
const composerRef = useRef<AgentComposerHandle | null>(null);
|
const composerRef = useRef<AgentComposerHandle | null>(null);
|
||||||
const hasResetForOpenRef = useRef(false);
|
const hasResetForOpenRef = useRef(false);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const { open: openNotification } = useNotification();
|
||||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -108,10 +112,34 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [createSession, isHydrating, open, scrollToBottom]);
|
}, [createSession, isHydrating, open, scrollToBottom]);
|
||||||
|
|
||||||
const handleSend = useCallback((prompt: string) => {
|
const handleSend = useCallback(async (prompt: string) => {
|
||||||
if (isStreaming) return;
|
if (isStreaming || isCheckingAuth) return;
|
||||||
|
|
||||||
|
setIsCheckingAuth(true);
|
||||||
|
try {
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
|
if (!accessToken) {
|
||||||
|
composerRef.current?.setValue(prompt);
|
||||||
|
openNotification?.({
|
||||||
|
type: "error",
|
||||||
|
message: "登录状态已失效",
|
||||||
|
description: "请重新登录后再发送对话。",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void sendPrompt(prompt);
|
void sendPrompt(prompt);
|
||||||
}, [isStreaming, sendPrompt]);
|
} 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(() => {
|
const handleNewConversation = useCallback(() => {
|
||||||
handleStopSpeech();
|
handleStopSpeech();
|
||||||
@@ -330,7 +358,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
<AgentComposer
|
<AgentComposer
|
||||||
ref={composerRef}
|
ref={composerRef}
|
||||||
isHydrating={isHydrating}
|
isHydrating={isHydrating || isCheckingAuth}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
isListening={isListening}
|
isListening={isListening}
|
||||||
isSttSupported={isSttSupported}
|
isSttSupported={isSttSupported}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ jest.mock("@/lib/chatStream", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const listChatSessions = jest.fn();
|
const listChatSessions = jest.fn();
|
||||||
|
const deleteChatSession = jest.fn();
|
||||||
const saveActiveChatState = jest.fn();
|
const saveActiveChatState = jest.fn();
|
||||||
const updateChatSessionTitle = jest.fn();
|
const updateChatSessionTitle = jest.fn();
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ jest.mock("../chatStorage", () => ({
|
|||||||
sessionId: undefined,
|
sessionId: undefined,
|
||||||
branchGroups: [],
|
branchGroups: [],
|
||||||
})),
|
})),
|
||||||
deleteChatSession: jest.fn(async () => undefined),
|
deleteChatSession: (...args: unknown[]) => deleteChatSession(...args),
|
||||||
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
|
listChatSessions: (...args: unknown[]) => listChatSessions(...args),
|
||||||
loadChatSessionById: jest.fn(async () => ({
|
loadChatSessionById: jest.fn(async () => ({
|
||||||
title: "已存在会话",
|
title: "已存在会话",
|
||||||
@@ -41,6 +42,7 @@ jest.mock("../chatStorage", () => ({
|
|||||||
describe("useAgentChatSession", () => {
|
describe("useAgentChatSession", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
listChatSessions.mockReset();
|
listChatSessions.mockReset();
|
||||||
|
deleteChatSession.mockReset();
|
||||||
saveActiveChatState.mockReset();
|
saveActiveChatState.mockReset();
|
||||||
updateChatSessionTitle.mockReset();
|
updateChatSessionTitle.mockReset();
|
||||||
jest.mocked(abortAgentChat).mockReset();
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
@@ -49,6 +51,7 @@ describe("useAgentChatSession", () => {
|
|||||||
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||||
|
deleteChatSession.mockImplementation(async () => undefined);
|
||||||
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
|
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,6 +112,69 @@ describe("useAgentChatSession", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("persists a new conversation only after the stream is done", async () => {
|
||||||
listChatSessions.mockResolvedValue([]);
|
listChatSessions.mockResolvedValue([]);
|
||||||
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||||
@@ -287,6 +353,66 @@ describe("useAgentChatSession", () => {
|
|||||||
expect(abortAgentChat).toHaveBeenCalledWith("session-loaded");
|
expect(abortAgentChat).toHaveBeenCalledWith("session-loaded");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(abortAgentChat).toHaveBeenCalledWith("session-1");
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores generated session titles after the title was edited manually", async () => {
|
it("ignores generated session titles after the title was edited manually", async () => {
|
||||||
listChatSessions.mockResolvedValue([]);
|
listChatSessions.mockResolvedValue([]);
|
||||||
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
|||||||
@@ -130,6 +130,25 @@ const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
||||||
|
const completedProgress = completeRunningProgress(message.progress);
|
||||||
|
const hasVisibleOutput =
|
||||||
|
message.content.trim().length > 0 ||
|
||||||
|
Boolean(message.artifacts?.length) ||
|
||||||
|
Boolean(completedProgress?.length);
|
||||||
|
|
||||||
|
if (!hasVisibleOutput) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: message.content || "⚠️ **请求已中断**",
|
||||||
|
isError: true,
|
||||||
|
progress: completedProgress,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const createUserMessage = (content: string, branchRootId?: string): Message => {
|
const createUserMessage = (content: string, branchRootId?: string): Message => {
|
||||||
const id = createId();
|
const id = createId();
|
||||||
return {
|
return {
|
||||||
@@ -605,6 +624,17 @@ export const useAgentChatSession = ({
|
|||||||
const controller = abortRef.current;
|
const controller = abortRef.current;
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
|
const assistantMessageId = getLastAssistantMessageId();
|
||||||
|
|
||||||
|
if (assistantMessageId) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? finalizeAssistantMessageAfterAbort(message)
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const cancelPromise = abortAgentChat(sessionIdRef.current).catch((error) => {
|
const cancelPromise = abortAgentChat(sessionIdRef.current).catch((error) => {
|
||||||
console.error("[GlobalChatbox] Failed to abort agent session:", error);
|
console.error("[GlobalChatbox] Failed to abort agent session:", error);
|
||||||
@@ -615,7 +645,7 @@ export const useAgentChatSession = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
cancelPromiseRef.current = trackedCancelPromise;
|
cancelPromiseRef.current = trackedCancelPromise;
|
||||||
}, []);
|
}, [getLastAssistantMessageId]);
|
||||||
|
|
||||||
const createSession = useCallback(() => {
|
const createSession = useCallback(() => {
|
||||||
if (isHydrating || isStreaming) return;
|
if (isHydrating || isStreaming) return;
|
||||||
@@ -683,6 +713,10 @@ export const useAgentChatSession = ({
|
|||||||
async (targetSessionId: string) => {
|
async (targetSessionId: string) => {
|
||||||
if (isHydrating || isStreaming) return;
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
|
setChatSessions((prev) =>
|
||||||
|
prev.filter((session) => session.id !== targetSessionId),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextActiveSessionId = await deleteChatSession(
|
const nextActiveSessionId = await deleteChatSession(
|
||||||
targetSessionId,
|
targetSessionId,
|
||||||
@@ -732,6 +766,11 @@ export const useAgentChatSession = ({
|
|||||||
setChatSessions(sessionsAfterDelete);
|
setChatSessions(sessionsAfterDelete);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[GlobalChatbox] Failed to delete chat session:", error);
|
console.error("[GlobalChatbox] Failed to delete chat session:", error);
|
||||||
|
try {
|
||||||
|
setChatSessions(await listChatSessions());
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.error("[GlobalChatbox] Failed to refresh chat sessions:", refreshError);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsHydrating(false);
|
setIsHydrating(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user