2 Commits

Author SHA1 Message Date
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
3 changed files with 200 additions and 7 deletions
+32 -4
View File
@@ -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);
} }