feat: LLM intent router, direct chat execution, and Memory V2 light intervention
Wire chat intent routing with direct_chat on regular sessions, skill-selected short-circuit to Agent, memory light/heavy intervention tiers, and fix direct chat UI stuck streaming after completion. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+191
-17
@@ -19,6 +19,7 @@ import {
|
||||
rememberProjectContext,
|
||||
uploadMindSpaceAsset,
|
||||
resumeSession,
|
||||
startSession,
|
||||
subscribeAgentRunEvents,
|
||||
subscribeNotificationEvents,
|
||||
subscribeSessionEvents,
|
||||
@@ -82,21 +83,75 @@ function isDirectChatSessionId(sessionId?: string | null) {
|
||||
}
|
||||
|
||||
async function waitForAgentRun(runId: string): Promise<AgentRun> {
|
||||
return await waitForAgentRunWithDirectChatPreview(runId, {});
|
||||
}
|
||||
|
||||
const DIRECT_CHAT_SESSION_POLL_MS = 600;
|
||||
|
||||
async function waitForAgentRunWithDirectChatPreview(
|
||||
runId: string,
|
||||
handlers: {
|
||||
onSessionId?: (sessionId: string) => void;
|
||||
onMessages?: (messages: Message[]) => void;
|
||||
isCancelled?: () => boolean;
|
||||
},
|
||||
): Promise<AgentRun> {
|
||||
return await new Promise<AgentRun>((resolve, reject) => {
|
||||
let pollTimer: number | null = null;
|
||||
let pollingSessionId: string | null = null;
|
||||
|
||||
const stopPoll = () => {
|
||||
if (pollTimer != null) {
|
||||
window.clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = (sessionId: string) => {
|
||||
if (pollingSessionId === sessionId && pollTimer != null) return;
|
||||
pollingSessionId = sessionId;
|
||||
stopPoll();
|
||||
pollTimer = window.setInterval(() => {
|
||||
if (handlers.isCancelled?.()) return;
|
||||
void loadSessionDetail(sessionId)
|
||||
.then(({ messages }) => {
|
||||
const hasAssistant = messages.some(
|
||||
(message) =>
|
||||
message.role === 'assistant' &&
|
||||
message.metadata?.source === 'portal-direct-chat',
|
||||
);
|
||||
if (hasAssistant) {
|
||||
handlers.onMessages?.(messages);
|
||||
stopPoll();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, DIRECT_CHAT_SESSION_POLL_MS);
|
||||
};
|
||||
|
||||
const unsubscribe = subscribeAgentRunEvents(
|
||||
runId,
|
||||
(run) => {
|
||||
if (run.sessionId) {
|
||||
handlers.onSessionId?.(run.sessionId);
|
||||
if (isDirectChatSessionId(run.sessionId)) {
|
||||
startPolling(run.sessionId);
|
||||
}
|
||||
}
|
||||
if (run.status === 'succeeded') {
|
||||
stopPoll();
|
||||
unsubscribe();
|
||||
resolve(run);
|
||||
return;
|
||||
}
|
||||
if (run.status === 'failed') {
|
||||
stopPoll();
|
||||
unsubscribe();
|
||||
reject(new Error(run.error || '后台任务失败,请稍后重试'));
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
stopPoll();
|
||||
unsubscribe();
|
||||
reject(error);
|
||||
},
|
||||
@@ -820,7 +875,13 @@ export function useTKMindChat(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (rid) processEvent(event, rid, sessionId);
|
||||
if (
|
||||
rid ||
|
||||
(isDirectChatSessionId(sessionId) &&
|
||||
(event.type === 'UpdateConversation' || event.type === 'Finish'))
|
||||
) {
|
||||
processEvent(event, rid ?? '', sessionId);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (err instanceof ApiError && err.status === 401) return;
|
||||
@@ -905,13 +966,14 @@ export function useTKMindChat(
|
||||
limit: appConfig.sessionMessagePageSize,
|
||||
}),
|
||||
);
|
||||
const resumedPromise = options?.skipResume
|
||||
? Promise.resolve(options.seedSession ?? null)
|
||||
: withTransientConnectRetry(() =>
|
||||
resumeSession(sessionId, {
|
||||
skipReconcile: options?.skipReconcile ?? false,
|
||||
}),
|
||||
);
|
||||
const resumedPromise =
|
||||
options?.skipResume || isDirectChatSessionId(sessionId)
|
||||
? Promise.resolve(options.seedSession ?? null)
|
||||
: withTransientConnectRetry(() =>
|
||||
resumeSession(sessionId, {
|
||||
skipReconcile: options?.skipReconcile ?? false,
|
||||
}),
|
||||
);
|
||||
const { session: detail, messages: history, page } = await detailPromise;
|
||||
if (token !== connectTokenRef.current) return;
|
||||
|
||||
@@ -1136,7 +1198,7 @@ export function useTKMindChat(
|
||||
const submit = useCallback(
|
||||
async (
|
||||
text: string,
|
||||
options?: { mindspaceContext?: MindSpaceChatContext; messageId?: string; forceDeepReasoning?: boolean },
|
||||
options?: { mindspaceContext?: MindSpaceChatContext; messageId?: string; forceDeepReasoning?: boolean; selectedChatSkill?: string },
|
||||
imageUrls?: string[],
|
||||
previewImageUrls?: string[],
|
||||
) => {
|
||||
@@ -1159,6 +1221,7 @@ export function useTKMindChat(
|
||||
const userPrefix = buildUserAddressPrefix(userRef.current);
|
||||
const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []);
|
||||
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}`;
|
||||
const priorMessageCount = messagesRef.current.length;
|
||||
const userMessage = buildUserMessage(trimmed, {
|
||||
id: options?.messageId,
|
||||
agentText: `${agentPrefix}${trimmed}`,
|
||||
@@ -1166,7 +1229,18 @@ export function useTKMindChat(
|
||||
imageUrls: normalizedImageUrls,
|
||||
previewImageUrls: normalizedPreviewImageUrls,
|
||||
});
|
||||
userMessage.metadata = {
|
||||
...userMessage.metadata,
|
||||
memindRun: {
|
||||
...(userMessage.metadata?.memindRun && typeof userMessage.metadata.memindRun === 'object'
|
||||
? userMessage.metadata.memindRun
|
||||
: {}),
|
||||
sessionMessageCount: priorMessageCount,
|
||||
...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}),
|
||||
},
|
||||
};
|
||||
const requestId = crypto.randomUUID();
|
||||
const submitToken = connectTokenRef.current;
|
||||
activeRequestId.current = requestId;
|
||||
messagesRef.current = [...messagesRef.current, userMessage];
|
||||
messageHistoryLoadedCountRef.current = messagesRef.current.length;
|
||||
@@ -1198,7 +1272,35 @@ export function useTKMindChat(
|
||||
},
|
||||
);
|
||||
const finishedRun =
|
||||
createdRun.status === 'succeeded' ? createdRun : await waitForAgentRun(createdRun.id);
|
||||
createdRun.status === 'succeeded'
|
||||
? createdRun
|
||||
: await waitForAgentRunWithDirectChatPreview(createdRun.id, {
|
||||
isCancelled: () => submitToken !== connectTokenRef.current,
|
||||
onSessionId: (sessionId) => {
|
||||
if (submitToken !== connectTokenRef.current) return;
|
||||
if (activeSessionId === sessionId) return;
|
||||
activeSessionId = sessionId;
|
||||
const nextSession: Session = {
|
||||
id: sessionId,
|
||||
name: 'New Chat',
|
||||
message_count: messagesRef.current.length,
|
||||
working_dir: '',
|
||||
};
|
||||
writeStoredSessionId(userRef.current?.id, sessionId);
|
||||
setSession(nextSession);
|
||||
setSessions((prev) => prependUnique(prev, nextSession));
|
||||
},
|
||||
onMessages: (snapshotMessages) => {
|
||||
if (submitToken !== connectTokenRef.current) return;
|
||||
messagesRef.current = mergeConversationSnapshot(
|
||||
messagesRef.current,
|
||||
snapshotMessages,
|
||||
);
|
||||
messageHistoryLoadedCountRef.current = messagesRef.current.length;
|
||||
setMessages(messagesRef.current);
|
||||
},
|
||||
});
|
||||
if (submitToken !== connectTokenRef.current) return;
|
||||
activeSessionId = finishedRun.sessionId;
|
||||
if (!activeSessionId) {
|
||||
throw new Error('后台任务已提交,但未返回会话');
|
||||
@@ -1226,8 +1328,25 @@ export function useTKMindChat(
|
||||
void loadProjectMemory(activeSessionId, false);
|
||||
void refreshSessions();
|
||||
}
|
||||
subscribeToSession(activeSessionId);
|
||||
setChatState('streaming');
|
||||
if (isDirectChatSessionId(activeSessionId)) {
|
||||
try {
|
||||
const detail = await loadSessionDetail(activeSessionId);
|
||||
if (submitToken === connectTokenRef.current) {
|
||||
messagesRef.current = mergeConversationSnapshot(messagesRef.current, detail.messages);
|
||||
messageHistoryLoadedCountRef.current = messagesRef.current.length;
|
||||
setMessages(messagesRef.current);
|
||||
setSession(detail.session);
|
||||
}
|
||||
} catch {
|
||||
// Messages may already be present from the direct-chat preview poll.
|
||||
}
|
||||
clearActiveRequestMissingTimer();
|
||||
activeRequestId.current = null;
|
||||
setChatState('idle');
|
||||
} else {
|
||||
subscribeToSession(activeSessionId);
|
||||
setChatState('streaming');
|
||||
}
|
||||
} catch (err) {
|
||||
if (activeSessionId && isAmbiguousReplySubmitError(err)) {
|
||||
subscribeToSession(activeSessionId);
|
||||
@@ -1288,10 +1407,20 @@ export function useTKMindChat(
|
||||
[session, pendingTool],
|
||||
);
|
||||
|
||||
const newSession = useCallback(() => {
|
||||
if (chatState === 'streaming' || chatState === 'waiting') return;
|
||||
const newSession = useCallback(async () => {
|
||||
const token = ++connectTokenRef.current;
|
||||
const previousSession = sessionRef.current;
|
||||
const previousSessionId = previousSession?.id ?? null;
|
||||
const pendingRequestId = activeRequestId.current;
|
||||
|
||||
if (previousSessionId && pendingRequestId) {
|
||||
try {
|
||||
await cancelRequest(previousSessionId, pendingRequestId);
|
||||
} catch {
|
||||
// Best-effort: the user is explicitly abandoning the in-flight turn.
|
||||
}
|
||||
}
|
||||
|
||||
connectTokenRef.current += 1;
|
||||
unsubscribeRef.current?.();
|
||||
unsubscribeRef.current = null;
|
||||
clearActiveRequestMissingTimer();
|
||||
@@ -1308,8 +1437,53 @@ export function useTKMindChat(
|
||||
setSession(null);
|
||||
setError(null);
|
||||
setPendingTool(null);
|
||||
setChatState('idle');
|
||||
}, [chatState, clearActiveRequestMissingTimer]);
|
||||
setChatState('connecting');
|
||||
|
||||
if (
|
||||
previousSessionId &&
|
||||
previousSession &&
|
||||
shouldShowNewChatTitle(toSessionSummary(previousSession))
|
||||
) {
|
||||
try {
|
||||
await deleteChatSession(previousSessionId);
|
||||
if (token === connectTokenRef.current) {
|
||||
setSessions((prev) => prev.filter((item) => item.id !== previousSessionId));
|
||||
}
|
||||
} catch {
|
||||
// Keep the abandoned empty session in history if cleanup fails.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const started = await startSession();
|
||||
if (token !== connectTokenRef.current) return;
|
||||
|
||||
const nextSession: Session = {
|
||||
...started,
|
||||
name: started.name || 'New Chat',
|
||||
working_dir: userRef.current?.workspaceRoot ?? started.working_dir ?? '',
|
||||
conversation: null,
|
||||
};
|
||||
writeStoredSessionId(userRef.current?.id, nextSession.id);
|
||||
setSession(nextSession);
|
||||
setSessions((prev) => prependUnique(prev, toSessionSummary(nextSession)));
|
||||
await ensureProvider(nextSession.id);
|
||||
if (token !== connectTokenRef.current) return;
|
||||
subscribeToSession(nextSession.id);
|
||||
setChatState('idle');
|
||||
void refreshSessions();
|
||||
} catch (err) {
|
||||
if (token !== connectTokenRef.current) return;
|
||||
setSession(null);
|
||||
setChatState('idle');
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [
|
||||
clearActiveRequestMissingTimer,
|
||||
ensureProvider,
|
||||
refreshSessions,
|
||||
subscribeToSession,
|
||||
]);
|
||||
|
||||
const refreshProjectMemory = useCallback(async () => {
|
||||
if (!session || chatState === 'streaming' || chatState === 'waiting') return;
|
||||
|
||||
Reference in New Issue
Block a user