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:
john
2026-07-04 22:32:57 +08:00
parent bfb6356f7d
commit e45c9300bf
33 changed files with 3412 additions and 105 deletions
+191 -17
View File
@@ -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;