Files
TJWaterFrontend_Refine/src/components/chat/hooks/useAgentChatSession.ts
T
jiang 0dad61ff1f
Generic Container CI/CD / test-build-publish (push) Successful in 2m25s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m25s
feat(chat): 优化分析过程与结果展示
2026-08-26 18:05:03 +08:00

1106 lines
36 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useSession } from "next-auth/react";
import {
abortAgentChat,
forkAgentChat,
rejectAgentQuestion,
replyAgentCredentialRefresh,
replyAgentPermission,
replyAgentQuestion,
resumeAgentChatStream,
streamAgentChat,
} from "@/lib/chatStream";
import type { PermissionDecision, StreamEvent } from "@/lib/chatStream";
import { useAuthStore } from "@/store/authStore";
import type {
AgentArtifact,
ChatSessionSummary,
Message,
} from "../GlobalChatbox.types";
import { cloneMessages } from "../globalChatboxUtils";
import {
createEmptyChatState,
deleteChatSession,
listChatSessions,
loadChatSessionById,
updateChatSessionTitle,
} from "../chatStorage";
import {
applyQuestionResponse,
cancelRunningTodos,
completeRunningActivities,
completeRunningProgress,
createAssistantMessage,
createTodoUpdateFromEvent,
createUserMessage,
dedupeQuestionsAcrossMessages,
finalizeAssistantMessageAfterAbort,
normalizeSessionTodos,
toPermissionStatus,
upsertPermission,
upsertActivity,
upsertProgress,
upsertQuestionAcrossMessages,
} from "./agentChatSessionState";
import type {
PromptRunOptions,
UseAgentChatSessionOptions,
} from "./useAgentChatSession.types";
const completeTodos = (todoUpdate: Message["todos"]) =>
todoUpdate
? {
...todoUpdate,
todos: todoUpdate.todos.map((todo) =>
todo.status === "pending" || todo.status === "in_progress"
? {
...todo,
status: "completed" as const,
updatedAt: Date.now(),
}
: todo,
),
}
: undefined;
export const useAgentChatSession = ({
projectId,
onToolCall,
onBeforeSend,
getModel,
getApprovalMode,
}: UseAgentChatSessionOptions) => {
const { update: updateSession } = useSession();
const hydrationNonceRef = useRef(0);
const [messages, setMessages] = useState<Message[]>([]);
const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined);
const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [isHydrating, setIsHydrating] = useState(true);
const [loadingSessionId, setLoadingSessionId] = useState<string | undefined>(undefined);
const abortRef = useRef<AbortController | null>(null);
const sessionIdRef = useRef<string | undefined>(undefined);
const messagesRef = useRef<Message[]>([]);
const resumeStreamingSessionRef = useRef<((sessionId: string) => void) | null>(null);
const isSessionTitleManuallyEditedRef = useRef(false);
const cancelPromiseRef = useRef<Promise<void> | null>(null);
const titleUpdateNonceRef = useRef(0);
const credentialRefreshRequestIdsRef = useRef(new Set<string>());
useEffect(() => {
sessionIdRef.current = sessionId;
}, [sessionId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
const applyTokenContent = useCallback((assistantMessageId: string, content: string) => {
if (!content) return;
setMessages((prev) => {
const next = prev.map((message) =>
message.id === assistantMessageId
? {
...message,
content: message.content + content,
isError: false,
}
: message,
);
messagesRef.current = next;
return next;
});
}, []);
useEffect(() => {
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
}, [isSessionTitleManuallyEdited]);
useEffect(() => {
let cancelled = false;
const hydrate = async () => {
setIsHydrating(true);
if (!projectId) {
sessionIdRef.current = undefined;
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
setMessages([]);
setSessionTitle(undefined);
setIsSessionTitleManuallyEdited(false);
setSessionId(undefined);
setChatSessions([]);
setIsHydrating(false);
return;
}
try {
const sessions = await listChatSessions();
const streamingSession = sessions.find((session) => session.isStreaming);
const loadedState = streamingSession
? await loadChatSessionById(streamingSession.id)
: createEmptyChatState();
if (cancelled) return;
sessionIdRef.current = loadedState.sessionId;
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
setMessages(
normalizeSessionTodos(dedupeQuestionsAcrossMessages(loadedState.messages)),
);
setSessionTitle(loadedState.title);
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
setSessionId(loadedState.sessionId);
setChatSessions(sessions);
if (
loadedState.sessionId &&
(loadedState.isStreaming || streamingSession?.isStreaming)
) {
resumeStreamingSessionRef.current?.(loadedState.sessionId);
}
} catch (error) {
console.error("[GlobalChatbox] Failed to hydrate chat state:", error);
} finally {
if (!cancelled) {
setIsHydrating(false);
}
}
};
void hydrate();
return () => {
cancelled = true;
};
}, [projectId]);
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
setMessages((prev) =>
prev.map((message) =>
message.id === messageId
? {
...message,
artifacts: [...(message.artifacts ?? []), artifact],
}
: message,
),
);
}, []);
const getLastAssistantMessageId = useCallback((fallback?: string) => {
const assistant = [...messagesRef.current]
.reverse()
.find((message) => message.role === "assistant");
return assistant?.id ?? fallback;
}, []);
const handleCredentialRefresh = useCallback(
async (event: StreamEvent & { type: "credential_refresh_required" }) => {
if (
!event.sessionId ||
!event.requestId ||
credentialRefreshRequestIdsRef.current.has(event.requestId)
) {
return;
}
credentialRefreshRequestIdsRef.current.add(event.requestId);
try {
const refreshedSession = await updateSession({ forceRefresh: true });
if (
refreshedSession?.error ||
typeof refreshedSession?.accessToken !== "string" ||
!refreshedSession.accessToken
) {
throw new Error("登录凭据续期失败");
}
const authStore = useAuthStore.getState();
authStore.setAccessToken(refreshedSession.accessToken);
authStore.clearSessionExpired();
await replyAgentCredentialRefresh(event.sessionId, event.requestId);
} catch (error) {
useAuthStore.getState().markSessionExpired("refresh_failed");
const assistantMessageId = getLastAssistantMessageId();
if (assistantMessageId) {
const message = error instanceof Error ? error.message : String(error);
setMessages((prev) =>
prev.map((item) =>
item.id === assistantMessageId
? {
...item,
content: item.content || `⚠️ **${message}**`,
isError: true,
progress: upsertProgress(item.progress, {
type: "progress",
sessionId: event.sessionId,
id: `credential-refresh-${event.requestId}`,
phase: "credential_refresh",
status: "error",
title: "登录凭据续期失败",
detail: message,
}),
}
: item,
),
);
}
setIsStreaming(false);
}
},
[getLastAssistantMessageId, updateSession],
);
const applyStreamEvent = useCallback(
(
event: StreamEvent,
options?: {
assistantMessageId?: string;
},
) => {
if (
event.type !== "session_title" &&
"sessionId" in event &&
event.sessionId &&
event.sessionId !== sessionIdRef.current
) {
sessionIdRef.current = event.sessionId;
setSessionId(event.sessionId);
}
if (event.type === "state") {
const nextMessages = normalizeSessionTodos(
dedupeQuestionsAcrossMessages(cloneMessages(event.messages as Message[])),
);
messagesRef.current = nextMessages;
setMessages(nextMessages);
setIsStreaming(event.isStreaming);
return;
}
if (event.type === "session_title") {
const nextTitle = event.title.trim();
if (nextTitle && !isSessionTitleManuallyEditedRef.current) {
const currentSessionId = sessionIdRef.current;
const targetSessionId = event.sessionId || currentSessionId;
if (targetSessionId === currentSessionId) {
setSessionTitle(nextTitle);
}
if (targetSessionId) {
const currentNonce = ++titleUpdateNonceRef.current;
void updateChatSessionTitle(targetSessionId, nextTitle, {
isTitleManuallyEdited: false,
})
.then(() => listChatSessions())
.then((sessions) => {
if (titleUpdateNonceRef.current !== currentNonce) return;
setChatSessions(sessions);
})
.catch((error) => {
console.error("[GlobalChatbox] Failed to persist session title:", error);
});
}
}
return;
}
const assistantMessageId = getLastAssistantMessageId(options?.assistantMessageId);
if (!assistantMessageId) {
return;
}
if (event.type === "token") {
applyTokenContent(assistantMessageId, event.content);
} else if (event.type === "final_answer") {
setMessages((prev) => {
const next = prev.map((message) =>
message.id === assistantMessageId
? { ...message, content: event.content, isError: false }
: message,
);
messagesRef.current = next;
return next;
});
} else if (event.type === "activity_update") {
setMessages((prev) => {
const next = prev.map((message) =>
message.id === assistantMessageId
? { ...message, activities: upsertActivity(message.activities, event) }
: message,
);
return event.todos
? normalizeSessionTodos(
next,
{
sessionId: event.sessionId,
todos: event.todos,
createdAt: event.todosCreatedAt ?? Date.now(),
},
assistantMessageId,
)
: next;
});
} else if (event.type === "progress") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? { ...message, progress: upsertProgress(message.progress, event) }
: message,
),
);
} else if (event.type === "tool_call") {
onToolCall(event, {
assistantMessageId,
appendArtifact,
});
} else if (event.type === "permission_request") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
permissions: upsertPermission(message.permissions, event),
}
: message,
),
);
} else if (event.type === "permission_response") {
setMessages((prev) =>
prev.map((message) => {
if (message.id !== assistantMessageId || !message.permissions?.length) {
return message;
}
return {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === event.requestId
? {
...permission,
status: toPermissionStatus(event.reply),
repliedAt: Date.now(),
error: undefined,
}
: permission,
),
};
}),
);
} else if (event.type === "question_request") {
setMessages((prev) =>
upsertQuestionAcrossMessages(prev, event, assistantMessageId),
);
} else if (event.type === "question_response") {
setMessages((prev) =>
prev.map((message) =>
message.questions?.some((question) => question.requestId === event.requestId)
? {
...message,
questions: applyQuestionResponse(message.questions, event),
}
: message,
),
);
} else if (event.type === "todo_update") {
setMessages((prev) =>
normalizeSessionTodos(
prev,
createTodoUpdateFromEvent(event),
assistantMessageId,
),
);
} else if (event.type === "credential_refresh_required") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
progress: upsertProgress(message.progress, {
type: "progress",
sessionId: event.sessionId,
id: `credential-refresh-${event.requestId}`,
phase: "credential_refresh",
status: "running",
title: "正在续期登录凭据",
detail: `当前工具调用保持等待,最长 ${Math.ceil((event.timeoutMs ?? 30_000) / 1000)} 秒`,
startedAt: Date.now(),
}),
}
: message,
),
);
void handleCredentialRefresh(event);
} else if (event.type === "credential_refreshed") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
progress: upsertProgress(message.progress, {
type: "progress",
sessionId: event.sessionId,
id: `credential-refresh-${event.requestId}`,
phase: "credential_refresh",
status: "completed",
title: "登录凭据已续期",
}),
}
: message,
),
);
} else if (event.type === "credential_refresh_failed") {
useAuthStore.getState().markSessionExpired("refresh_failed");
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
content: message.content || `⚠️ **${event.message}**`,
isError: true,
progress: upsertProgress(message.progress, {
type: "progress",
sessionId: event.sessionId,
id: `credential-refresh-${event.requestId}`,
phase: "credential_refresh",
status: "error",
title: "登录凭据续期失败",
detail: event.message,
}),
}
: message,
),
);
setIsStreaming(false);
} else if (event.type === "done") {
setMessages((prev) =>
prev.map((message) => {
if (message.id !== assistantMessageId) return message;
const completedProgress = completeRunningProgress(message.progress);
const completedActivities = completeRunningActivities(message.activities);
if (
message.content.trim().length === 0 &&
!(message.artifacts?.length)
) {
return {
...message,
content:
"Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completedProgress,
activities: completedActivities,
todos: completeTodos(message.todos),
};
}
return {
...message,
progress: completedProgress,
activities: completedActivities,
todos: completeTodos(message.todos),
};
}),
);
setIsStreaming(false);
} else if (event.type === "error") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
content: message.content || `⚠️ **错误:** ${event.message}`,
isError: true,
progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
todos: cancelRunningTodos(message.todos),
}
: message,
),
);
setIsStreaming(false);
} else if (event.type === "auth_required") {
useAuthStore.getState().markSessionExpired("unauthorized");
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
content: message.content || `⚠️ **${event.message}**`,
isError: true,
progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
todos: cancelRunningTodos(message.todos),
}
: message,
),
);
setIsStreaming(false);
}
},
[
applyTokenContent,
appendArtifact,
getLastAssistantMessageId,
handleCredentialRefresh,
onToolCall,
],
);
const resumeStreamingSession = useCallback(
(nextSessionId: string) => {
const controller = new AbortController();
abortRef.current?.abort();
abortRef.current = controller;
setIsStreaming(true);
void resumeAgentChatStream({
sessionId: nextSessionId,
signal: controller.signal,
onEvent: (event) => applyStreamEvent(event),
})
.catch((error) => {
if (!controller.signal.aborted) {
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
setIsStreaming(false);
}
})
.finally(() => {
if (abortRef.current === controller) {
abortRef.current = null;
}
});
},
[applyStreamEvent],
);
resumeStreamingSessionRef.current = resumeStreamingSession;
const runPrompt = useCallback(
async ({
prompt: rawPrompt,
sessionIdOverride,
preparedMessages,
userMessage,
assistantMessage,
}: PromptRunOptions) => {
const prompt = rawPrompt.trim();
if (!prompt || isStreaming || isHydrating) return;
await cancelPromiseRef.current?.catch(() => undefined);
onBeforeSend?.();
const nextUserMessage = userMessage ?? createUserMessage(prompt);
const nextAssistantMessage = assistantMessage ?? createAssistantMessage();
const nextMessages =
preparedMessages ??
[...messages, nextUserMessage, nextAssistantMessage];
const clonedNextMessages = cloneMessages(nextMessages);
setIsStreaming(true);
messagesRef.current = clonedNextMessages;
setMessages(clonedNextMessages);
if (sessionIdOverride !== undefined) {
sessionIdRef.current = sessionIdOverride;
setSessionId(sessionIdOverride);
}
const controller = new AbortController();
abortRef.current = controller;
try {
await streamAgentChat({
message: prompt,
sessionId: sessionIdOverride ?? sessionIdRef.current,
model: getModel?.(),
approvalMode: getApprovalMode?.(),
signal: controller.signal,
onEvent: (event) =>
applyStreamEvent(event, {
assistantMessageId: nextAssistantMessage.id,
}),
});
} catch (error) {
if (controller.signal.aborted) {
setMessages((prev) =>
prev
.map((message) =>
message.id === nextAssistantMessage.id
? finalizeAssistantMessageAfterAbort(message)
: message,
)
.filter(
(message) =>
!(
message.id === nextAssistantMessage.id &&
message.role === "assistant" &&
message.content.trim().length === 0 &&
!(message.artifacts?.length) &&
!(message.progress?.length) &&
!(message.activities?.length) &&
!message.todos
),
),
);
return;
}
setMessages((prev) =>
prev.map((message) =>
message.id === nextAssistantMessage.id
? {
...message,
content: `⚠️ **错误:** ${String(error)}`,
isError: true,
progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
}
: message,
),
);
setIsStreaming(false);
} finally {
abortRef.current = null;
setIsStreaming(false);
}
},
[
applyStreamEvent,
getApprovalMode,
getModel,
isHydrating,
isStreaming,
messages,
onBeforeSend,
],
);
const abort = useCallback(() => {
const controller = abortRef.current;
controller?.abort();
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) => {
console.error("[GlobalChatbox] Failed to abort agent session:", error);
});
const trackedCancelPromise = cancelPromise.finally(() => {
if (cancelPromiseRef.current === trackedCancelPromise) {
cancelPromiseRef.current = null;
}
});
cancelPromiseRef.current = trackedCancelPromise;
}, [getLastAssistantMessageId]);
const replyPermission = useCallback(
async (requestId: string, reply: PermissionDecision) => {
const target = messagesRef.current
.flatMap((message) => message.permissions ?? [])
.find((permission) => permission.requestId === requestId);
if (!target || target.status === "submitting") {
return;
}
setMessages((prev) =>
prev.map((message) =>
!message.permissions?.some((permission) => permission.requestId === requestId)
? message
: {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === requestId
? { ...permission, status: "submitting", error: undefined }
: permission,
),
},
),
);
try {
await replyAgentPermission(target.sessionId, requestId, reply);
setMessages((prev) =>
prev.map((message) =>
!message.permissions?.some((permission) => permission.requestId === requestId)
? message
: {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === requestId
? {
...permission,
status: toPermissionStatus(reply),
repliedAt: Date.now(),
error: undefined,
}
: permission,
),
},
),
);
} catch (error) {
setMessages((prev) =>
prev.map((message) =>
!message.permissions?.some((permission) => permission.requestId === requestId)
? message
: {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === requestId
? {
...permission,
status: "error",
error: error instanceof Error ? error.message : String(error),
}
: permission,
),
},
),
);
}
},
[],
);
const replyQuestion = useCallback(
async (requestId: string, answers: string[][]) => {
const target = messagesRef.current
.flatMap((message) => message.questions ?? [])
.find((question) => question.requestId === requestId);
if (!target || target.status === "submitting") {
return;
}
setMessages((prev) =>
prev.map((message) =>
!message.questions?.some((question) => question.requestId === requestId)
? message
: {
...message,
questions: message.questions.map((question) =>
question.requestId === requestId
? { ...question, status: "submitting", error: undefined }
: question,
),
},
),
);
try {
await replyAgentQuestion(target.sessionId, requestId, answers);
setMessages((prev) =>
prev.map((message) =>
!message.questions?.some((question) => question.requestId === requestId)
? message
: {
...message,
questions: message.questions.map((question) =>
question.requestId === requestId
? {
...question,
status: "answered",
answers,
repliedAt: Date.now(),
error: undefined,
}
: question,
),
},
),
);
} catch (error) {
setMessages((prev) =>
prev.map((message) =>
!message.questions?.some((question) => question.requestId === requestId)
? message
: {
...message,
questions: message.questions.map((question) =>
question.requestId === requestId
? {
...question,
status: "error",
error: error instanceof Error ? error.message : String(error),
}
: question,
),
},
),
);
}
},
[],
);
const rejectQuestion = useCallback(
async (requestId: string) => {
const target = messagesRef.current
.flatMap((message) => message.questions ?? [])
.find((question) => question.requestId === requestId);
if (!target || target.status === "submitting") {
return;
}
setMessages((prev) =>
prev.map((message) =>
!message.questions?.some((question) => question.requestId === requestId)
? message
: {
...message,
questions: message.questions.map((question) =>
question.requestId === requestId
? { ...question, status: "submitting", error: undefined }
: question,
),
},
),
);
try {
await rejectAgentQuestion(target.sessionId, requestId);
setMessages((prev) =>
prev.map((message) =>
!message.questions?.some((question) => question.requestId === requestId)
? message
: {
...message,
questions: message.questions.map((question) =>
question.requestId === requestId
? {
...question,
status: "rejected",
repliedAt: Date.now(),
error: undefined,
}
: question,
),
},
),
);
} catch (error) {
setMessages((prev) =>
prev.map((message) =>
!message.questions?.some((question) => question.requestId === requestId)
? message
: {
...message,
questions: message.questions.map((question) =>
question.requestId === requestId
? {
...question,
status: "error",
error: error instanceof Error ? error.message : String(error),
}
: question,
),
},
),
);
}
},
[],
);
const createSession = useCallback(() => {
if (isHydrating || isStreaming) return;
const controller = abortRef.current;
controller?.abort();
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
sessionIdRef.current = undefined;
setMessages([]);
setSessionTitle("新对话");
setIsSessionTitleManuallyEdited(false);
setSessionId(undefined);
setIsStreaming(false);
}, [isHydrating, isStreaming]);
const switchSession = useCallback(
async (nextSessionId: string, optimisticTitle?: string) => {
if (isHydrating || isStreaming || sessionIdRef.current === nextSessionId) {
return;
}
setIsHydrating(true);
setLoadingSessionId(nextSessionId);
const nextTitle = optimisticTitle?.trim();
if (nextTitle) {
setSessionTitle(nextTitle);
}
try {
const [nextState, sessions] = await Promise.all([
loadChatSessionById(nextSessionId),
listChatSessions(),
]);
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
sessionIdRef.current = nextState.sessionId;
setMessages(nextState.messages);
setSessionTitle(nextState.title);
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
setSessionId(nextState.sessionId);
setChatSessions(sessions);
if (nextState.sessionId && nextState.isStreaming) {
resumeStreamingSession(nextState.sessionId);
} else {
setIsStreaming(false);
}
} catch (error) {
console.error("[GlobalChatbox] Failed to switch chat session:", error);
} finally {
setLoadingSessionId(undefined);
setIsHydrating(false);
}
},
[isHydrating, isStreaming, resumeStreamingSession],
);
const removeSession = useCallback(
async (targetSessionId: string) => {
if (isHydrating || isStreaming) return;
setChatSessions((prev) =>
prev.filter((session) => session.id !== targetSessionId),
);
try {
const nextActiveSessionId = await deleteChatSession(
targetSessionId,
);
const sessions = await listChatSessions();
setChatSessions(sessions);
if (sessionIdRef.current !== targetSessionId) {
return;
}
if (!nextActiveSessionId) {
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
sessionIdRef.current = undefined;
setMessages([]);
setSessionTitle(undefined);
setIsSessionTitleManuallyEdited(false);
setSessionId(undefined);
return;
}
setIsHydrating(true);
const [nextState, sessionsAfterDelete] = await Promise.all([
loadChatSessionById(nextActiveSessionId),
listChatSessions(),
]);
hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1;
sessionIdRef.current = nextState.sessionId;
setMessages(nextState.messages);
setSessionTitle(nextState.title);
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
setSessionId(nextState.sessionId);
setChatSessions(sessionsAfterDelete);
} catch (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 {
setIsHydrating(false);
}
},
[isHydrating, isStreaming],
);
const sendPrompt = useCallback(
async (rawPrompt: string) => {
await runPrompt({ prompt: rawPrompt });
},
[runPrompt],
);
const renameSession = useCallback(
async (targetSessionId: string, nextTitle: string) => {
const normalizedTitle = nextTitle.trim();
if (!normalizedTitle || isHydrating) return;
try {
await updateChatSessionTitle(targetSessionId, normalizedTitle, {
isTitleManuallyEdited: true,
});
const sessions = await listChatSessions();
setChatSessions(sessions);
if (sessionIdRef.current === targetSessionId) {
setSessionTitle(normalizedTitle);
setIsSessionTitleManuallyEdited(true);
}
} catch (error) {
console.error("[GlobalChatbox] Failed to rename chat session:", error);
}
},
[isHydrating],
);
const createBranch = useCallback(
async (messageId: string) => {
if (isHydrating || isStreaming) return;
const assistantIndex = messages.findIndex(
(message) => message.id === messageId && message.role === "assistant",
);
if (assistantIndex < 0) return;
const currentSessionId = sessionIdRef.current;
const keepMessageCount = assistantIndex + 1;
const copiedMessages = cloneMessages(messages.slice(0, keepMessageCount));
const forkedSessionId = await forkAgentChat(currentSessionId, keepMessageCount);
sessionIdRef.current = forkedSessionId;
setSessionId(forkedSessionId);
messagesRef.current = copiedMessages;
setMessages(copiedMessages);
setIsSessionTitleManuallyEdited(false);
const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本";
setSessionTitle(forkTitle);
try {
setChatSessions(await listChatSessions());
} catch (error) {
console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
}
},
[isHydrating, isStreaming, messages, sessionTitle],
);
return {
messages,
chatSessions,
activeSessionId: sessionIdRef.current,
isHydrating,
loadingSessionId,
isStreaming,
sessionTitle,
sessionId,
sendPrompt,
createBranch,
abort,
replyPermission,
replyQuestion,
rejectQuestion,
createSession,
renameSession,
removeSession,
switchSession,
};
};