1030 lines
33 KiB
TypeScript
1030 lines
33 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
|
||
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
||
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
||
import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types";
|
||
import { cloneMessages } from "../GlobalChatbox.utils";
|
||
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage";
|
||
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
||
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
||
|
||
const TOKEN_PLAYBACK_INTERVAL_MS = 16;
|
||
const TOKEN_PLAYBACK_BASE_CHARS = 28;
|
||
const TOKEN_PLAYBACK_MAX_CHARS = 160;
|
||
|
||
const sliceCodePoints = (value: string, count: number) =>
|
||
Array.from(value).slice(0, count).join("");
|
||
|
||
let cachedSegmenter: Intl.Segmenter | null | undefined;
|
||
|
||
const getSegmenter = () => {
|
||
if (cachedSegmenter !== undefined) return cachedSegmenter;
|
||
cachedSegmenter =
|
||
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||
? new Intl.Segmenter("zh", { granularity: "word" })
|
||
: null;
|
||
return cachedSegmenter;
|
||
};
|
||
|
||
const getPlaybackChunkSize = (bufferLength: number) => {
|
||
if (bufferLength >= 600) return TOKEN_PLAYBACK_MAX_CHARS;
|
||
if (bufferLength >= 300) return 112;
|
||
if (bufferLength >= 140) return 72;
|
||
if (bufferLength >= 64) return 44;
|
||
return TOKEN_PLAYBACK_BASE_CHARS;
|
||
};
|
||
|
||
const takeNextTokenPlaybackChunk = (content: string, maxChars: number) => {
|
||
if (content.length <= maxChars) return content;
|
||
const targetChars = Math.max(12, Math.floor(maxChars * 0.68));
|
||
|
||
const segmenter = getSegmenter();
|
||
if (segmenter) {
|
||
let chunk = "";
|
||
for (const segment of segmenter.segment(content)) {
|
||
chunk += segment.segment;
|
||
if (
|
||
chunk.length >= maxChars ||
|
||
(chunk.length >= targetChars &&
|
||
/[\s,。!?、;:,.!?;:]/u.test(segment.segment))
|
||
) {
|
||
return chunk;
|
||
}
|
||
}
|
||
}
|
||
|
||
const phrase = content.match(/^.{1,12}?[\s,。!?、;:,.!?;:]+/u)?.[0];
|
||
if (phrase) return phrase;
|
||
|
||
const cjkChunk = content.match(
|
||
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/u,
|
||
)?.[0];
|
||
if (cjkChunk) return sliceCodePoints(cjkChunk, Math.min(maxChars, 18));
|
||
|
||
const wordChunk = content.match(/^\S+\s*/u)?.[0];
|
||
if (wordChunk) {
|
||
return wordChunk.length <= maxChars
|
||
? wordChunk
|
||
: sliceCodePoints(wordChunk, maxChars);
|
||
}
|
||
|
||
return sliceCodePoints(content, Math.min(maxChars, 12));
|
||
};
|
||
|
||
export const useAgentChatSession = ({
|
||
projectId,
|
||
onToolCall,
|
||
onBeforeSend,
|
||
getModel,
|
||
getApprovalMode,
|
||
}: UseAgentChatSessionOptions) => {
|
||
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 pendingTokenRef = useRef<{
|
||
assistantMessageId: string;
|
||
content: string;
|
||
} | null>(null);
|
||
const tokenPlaybackIntervalRef = useRef<number | null>(null);
|
||
|
||
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;
|
||
});
|
||
}, []);
|
||
|
||
const cancelTokenPlayback = useCallback(() => {
|
||
const intervalId = tokenPlaybackIntervalRef.current;
|
||
if (intervalId === null) return;
|
||
window.clearInterval(intervalId);
|
||
tokenPlaybackIntervalRef.current = null;
|
||
}, []);
|
||
|
||
const flushPendingTokens = useCallback(() => {
|
||
const pending = pendingTokenRef.current;
|
||
pendingTokenRef.current = null;
|
||
cancelTokenPlayback();
|
||
if (!pending) return;
|
||
applyTokenContent(pending.assistantMessageId, pending.content);
|
||
}, [applyTokenContent, cancelTokenPlayback]);
|
||
|
||
const scheduleTokenPlayback = useCallback(() => {
|
||
if (tokenPlaybackIntervalRef.current !== null) return;
|
||
const id = window.setInterval(() => {
|
||
const pending = pendingTokenRef.current;
|
||
if (!pending) {
|
||
window.clearInterval(id);
|
||
tokenPlaybackIntervalRef.current = null;
|
||
return;
|
||
}
|
||
|
||
const chunk = takeNextTokenPlaybackChunk(
|
||
pending.content,
|
||
getPlaybackChunkSize(pending.content.length),
|
||
);
|
||
if (!chunk) {
|
||
window.clearInterval(id);
|
||
tokenPlaybackIntervalRef.current = null;
|
||
pendingTokenRef.current = null;
|
||
return;
|
||
}
|
||
|
||
const remaining = pending.content.slice(chunk.length);
|
||
pendingTokenRef.current = remaining
|
||
? { assistantMessageId: pending.assistantMessageId, content: remaining }
|
||
: null;
|
||
applyTokenContent(pending.assistantMessageId, chunk);
|
||
|
||
if (!remaining) {
|
||
window.clearInterval(id);
|
||
tokenPlaybackIntervalRef.current = null;
|
||
}
|
||
}, TOKEN_PLAYBACK_INTERVAL_MS);
|
||
tokenPlaybackIntervalRef.current = id;
|
||
}, [applyTokenContent]);
|
||
|
||
const queueTokenContent = useCallback(
|
||
(assistantMessageId: string, content: string) => {
|
||
const pending = pendingTokenRef.current;
|
||
if (pending && pending.assistantMessageId !== assistantMessageId) {
|
||
flushPendingTokens();
|
||
}
|
||
pendingTokenRef.current = {
|
||
assistantMessageId,
|
||
content:
|
||
pending?.assistantMessageId === assistantMessageId
|
||
? pending.content + content
|
||
: content,
|
||
};
|
||
scheduleTokenPlayback();
|
||
},
|
||
[flushPendingTokens, scheduleTokenPlayback],
|
||
);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
pendingTokenRef.current = null;
|
||
cancelTokenPlayback();
|
||
},
|
||
[cancelTokenPlayback],
|
||
);
|
||
|
||
|
||
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 applyStreamEvent = useCallback(
|
||
(
|
||
event: StreamEvent,
|
||
options?: {
|
||
assistantMessageId?: string;
|
||
},
|
||
) => {
|
||
if (event.type !== "token") {
|
||
flushPendingTokens();
|
||
}
|
||
|
||
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") {
|
||
queueTokenContent(assistantMessageId, event.content);
|
||
} 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 === "done") {
|
||
setMessages((prev) =>
|
||
prev.map((message) => {
|
||
if (message.id !== assistantMessageId) return message;
|
||
const completedProgress = completeRunningProgress(message.progress);
|
||
if (
|
||
message.content.trim().length === 0 &&
|
||
!(message.artifacts?.length)
|
||
) {
|
||
return {
|
||
...message,
|
||
content:
|
||
"Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
|
||
progress: completedProgress,
|
||
};
|
||
}
|
||
return { ...message, progress: completedProgress };
|
||
}),
|
||
);
|
||
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),
|
||
todos: cancelRunningTodos(message.todos),
|
||
}
|
||
: message,
|
||
),
|
||
);
|
||
setIsStreaming(false);
|
||
}
|
||
},
|
||
[
|
||
appendArtifact,
|
||
flushPendingTokens,
|
||
getLastAssistantMessageId,
|
||
onToolCall,
|
||
queueTokenContent,
|
||
],
|
||
);
|
||
|
||
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) => {
|
||
flushPendingTokens();
|
||
if (!controller.signal.aborted) {
|
||
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
|
||
setIsStreaming(false);
|
||
}
|
||
})
|
||
.finally(() => {
|
||
flushPendingTokens();
|
||
if (abortRef.current === controller) {
|
||
abortRef.current = null;
|
||
}
|
||
});
|
||
},
|
||
[applyStreamEvent, flushPendingTokens],
|
||
);
|
||
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) {
|
||
flushPendingTokens();
|
||
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.todos
|
||
),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
setMessages((prev) =>
|
||
prev.map((message) =>
|
||
message.id === nextAssistantMessage.id
|
||
? {
|
||
...message,
|
||
content: `⚠️ **错误:** ${String(error)}`,
|
||
isError: true,
|
||
progress: completeRunningProgress(message.progress),
|
||
}
|
||
: message,
|
||
),
|
||
);
|
||
setIsStreaming(false);
|
||
} finally {
|
||
flushPendingTokens();
|
||
abortRef.current = null;
|
||
setIsStreaming(false);
|
||
}
|
||
},
|
||
[
|
||
applyStreamEvent,
|
||
flushPendingTokens,
|
||
getApprovalMode,
|
||
getModel,
|
||
isHydrating,
|
||
isStreaming,
|
||
messages,
|
||
onBeforeSend,
|
||
],
|
||
);
|
||
|
||
const abort = useCallback(() => {
|
||
const controller = abortRef.current;
|
||
controller?.abort();
|
||
flushPendingTokens();
|
||
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;
|
||
}, [flushPendingTokens, getLastAssistantMessageId]);
|
||
|
||
const replyPermission = useCallback(
|
||
async (requestId: string, reply: PermissionReply) => {
|
||
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;
|
||
|
||
flushPendingTokens();
|
||
const controller = abortRef.current;
|
||
controller?.abort();
|
||
hydrationNonceRef.current += 1;
|
||
titleUpdateNonceRef.current += 1;
|
||
sessionIdRef.current = undefined;
|
||
setMessages([]);
|
||
setSessionTitle("新对话");
|
||
setIsSessionTitleManuallyEdited(false);
|
||
setSessionId(undefined);
|
||
setIsStreaming(false);
|
||
}, [flushPendingTokens, 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,
|
||
};
|
||
};
|