feat(agent): 聊天提交统一走 POST /agent/runs 异步网关
引入 Agent Run 网关替代直连 /sessions/:id/reply,并在 api_lockdown 白名单中放行新入口,避免策略拦截导致聊天不可用。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,12 +4,13 @@ import {
|
||||
cancelRequest,
|
||||
closeMindSpacePageEditSession,
|
||||
confirmTool,
|
||||
createAgentRun,
|
||||
forkMindSpacePageEditSession,
|
||||
getAgentRun,
|
||||
getMindSpace,
|
||||
loadSessionDetail,
|
||||
resumeSession,
|
||||
uploadMindSpaceAsset,
|
||||
sendReply,
|
||||
subscribeSessionEvents,
|
||||
} from '../api/client';
|
||||
import type { ChatState, Message, MindSpaceChatContext, PortalUser, Session, SessionEvent } from '../types';
|
||||
@@ -30,6 +31,21 @@ import {
|
||||
pushMessage,
|
||||
} from '../utils/message';
|
||||
|
||||
const AGENT_RUN_POLL_DELAY_MS = 1200;
|
||||
const AGENT_RUN_MAX_POLLS = 100;
|
||||
|
||||
async function waitForAgentRun(runId: string) {
|
||||
for (let attempt = 0; attempt < AGENT_RUN_MAX_POLLS; attempt += 1) {
|
||||
const run = await getAgentRun(runId);
|
||||
if (run.status === 'succeeded') return run;
|
||||
if (run.status === 'failed') {
|
||||
throw new Error(run.error || '后台任务失败,请稍后重试');
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, AGENT_RUN_POLL_DELAY_MS));
|
||||
}
|
||||
throw new Error('后台任务仍在排队,请稍后刷新会话查看结果');
|
||||
}
|
||||
|
||||
function buildPageEditSummary(messages: Message[], pageTitle: string): string {
|
||||
const lines = messages
|
||||
.map((message) => {
|
||||
@@ -288,7 +304,12 @@ export function usePageEditSubChat({
|
||||
(value) => typeof value === 'string' && value.trim(),
|
||||
);
|
||||
if (!currentSession || (!text.trim() && normalizedImageUrls.length === 0)) return;
|
||||
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
|
||||
if (
|
||||
chatState === 'streaming' ||
|
||||
chatState === 'loading' ||
|
||||
chatState === 'connecting' ||
|
||||
chatState === 'waiting'
|
||||
) return;
|
||||
|
||||
const trimmed = text.trim();
|
||||
const mindspacePrefix = buildContextPrefix(context);
|
||||
@@ -304,12 +325,18 @@ export function usePageEditSubChat({
|
||||
activeRequestId.current = requestId;
|
||||
messagesRef.current = [...messagesRef.current, userMessage];
|
||||
setMessages(messagesRef.current);
|
||||
setChatState('streaming');
|
||||
setChatState('waiting');
|
||||
setError(null);
|
||||
setPendingTool(null);
|
||||
|
||||
try {
|
||||
await sendReply(currentSession.id, requestId, userMessage);
|
||||
const createdRun = await createAgentRun(currentSession.id, requestId, userMessage);
|
||||
const finishedRun =
|
||||
createdRun.status === 'succeeded' ? createdRun : await waitForAgentRun(createdRun.id);
|
||||
if (!finishedRun.sessionId) {
|
||||
throw new Error('后台任务已提交,但未返回会话');
|
||||
}
|
||||
setChatState('streaming');
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 402) {
|
||||
setNotice('余额不足,请充值后继续使用');
|
||||
|
||||
+73
-36
@@ -5,7 +5,9 @@ import {
|
||||
bootstrapProjectMemory,
|
||||
cancelRequest,
|
||||
confirmTool,
|
||||
createAgentRun,
|
||||
deleteChatSession,
|
||||
getAgentRun,
|
||||
getMindSpace,
|
||||
getMe,
|
||||
listNotifications,
|
||||
@@ -17,8 +19,6 @@ import {
|
||||
rememberProjectContext,
|
||||
uploadMindSpaceAsset,
|
||||
resumeSession,
|
||||
sendReply,
|
||||
startSession,
|
||||
subscribeNotificationEvents,
|
||||
subscribeSessionEvents,
|
||||
updateProvider,
|
||||
@@ -69,9 +69,23 @@ import {
|
||||
|
||||
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
|
||||
const REPLY_RECOVERY_SYNC_DELAYS_MS = [1500, 5000, 12000];
|
||||
const AGENT_RUN_POLL_DELAY_MS = 1200;
|
||||
const AGENT_RUN_MAX_POLLS = 100;
|
||||
|
||||
export { INSUFFICIENT_BALANCE_NOTICE };
|
||||
|
||||
async function waitForAgentRun(runId: string) {
|
||||
for (let attempt = 0; attempt < AGENT_RUN_MAX_POLLS; attempt += 1) {
|
||||
const run = await getAgentRun(runId);
|
||||
if (run.status === 'succeeded') return run;
|
||||
if (run.status === 'failed') {
|
||||
throw new Error(run.error || '后台任务失败,请稍后重试');
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, AGENT_RUN_POLL_DELAY_MS));
|
||||
}
|
||||
throw new Error('后台任务仍在排队,请稍后刷新会话查看结果');
|
||||
}
|
||||
|
||||
function isAmbiguousReplySubmitError(err: unknown) {
|
||||
if (!(err instanceof ApiError)) return true;
|
||||
return err.status === 0 || err.status === 409 || err.status >= 500;
|
||||
@@ -444,9 +458,15 @@ export function useTKMindChat(
|
||||
}
|
||||
const retryRequestId = crypto.randomUUID();
|
||||
activeRequestId.current = retryRequestId;
|
||||
setChatState('streaming');
|
||||
setChatState('waiting');
|
||||
setError(null);
|
||||
await sendReply(sessionRef.current!.id, retryRequestId, lastUser);
|
||||
const retryRun = await createAgentRun(sessionRef.current!.id, retryRequestId, lastUser);
|
||||
const finishedRetryRun =
|
||||
retryRun.status === 'succeeded' ? retryRun : await waitForAgentRun(retryRun.id);
|
||||
if (!finishedRetryRun.sessionId) {
|
||||
throw new Error('后台任务已提交,但未返回会话');
|
||||
}
|
||||
setChatState('streaming');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setChatState('error');
|
||||
@@ -741,7 +761,7 @@ export function useTKMindChat(
|
||||
|
||||
const switchSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
if (session?.id === sessionId || chatState === 'streaming') return;
|
||||
if (session?.id === sessionId || chatState === 'streaming' || chatState === 'waiting') return;
|
||||
|
||||
resetSessionView();
|
||||
const token = connectTokenRef.current;
|
||||
@@ -776,7 +796,12 @@ export function useTKMindChat(
|
||||
(value) => typeof value === 'string' && value.trim(),
|
||||
);
|
||||
if (!text.trim() && normalizedImageUrls.length === 0) return;
|
||||
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
|
||||
if (
|
||||
chatState === 'streaming' ||
|
||||
chatState === 'loading' ||
|
||||
chatState === 'connecting' ||
|
||||
chatState === 'waiting'
|
||||
) return;
|
||||
|
||||
const trimmed = text.trim();
|
||||
const mindspacePrefix = options?.mindspaceContext
|
||||
@@ -795,41 +820,48 @@ export function useTKMindChat(
|
||||
activeRequestId.current = requestId;
|
||||
messagesRef.current = [...messagesRef.current, userMessage];
|
||||
setMessages(messagesRef.current);
|
||||
setChatState('streaming');
|
||||
setChatState('waiting');
|
||||
setError(null);
|
||||
setPendingTool(null);
|
||||
|
||||
let activeSessionId = session?.id ?? null;
|
||||
|
||||
if (!activeSessionId) {
|
||||
try {
|
||||
const created = await startSession();
|
||||
activeSessionId = created.id;
|
||||
writeStoredSessionId(userRef.current?.id, created.id);
|
||||
setSession(created);
|
||||
setSessions((prev) => prependUnique(prev, created));
|
||||
subscribeToSession(created.id);
|
||||
void ensureProvider(created.id);
|
||||
void loadProjectMemory(created.id, false);
|
||||
void refreshSessions();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 402) {
|
||||
notifyInsufficientBalance();
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setChatState('error');
|
||||
}
|
||||
activeRequestId.current = null;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (activeSessionId) {
|
||||
setSessions((prev) => touchSession(prev, activeSessionId!, 1));
|
||||
}
|
||||
|
||||
try {
|
||||
await sendReply(activeSessionId, requestId, userMessage);
|
||||
const createdRun = await createAgentRun(activeSessionId, requestId, userMessage);
|
||||
const finishedRun =
|
||||
createdRun.status === 'succeeded' ? createdRun : await waitForAgentRun(createdRun.id);
|
||||
activeSessionId = finishedRun.sessionId;
|
||||
if (!activeSessionId) {
|
||||
throw new Error('后台任务已提交,但未返回会话');
|
||||
}
|
||||
if (!session?.id || session.id !== activeSessionId) {
|
||||
const nextSession: Session = {
|
||||
id: activeSessionId,
|
||||
name: 'New Chat',
|
||||
message_count: messagesRef.current.length,
|
||||
working_dir: '',
|
||||
};
|
||||
writeStoredSessionId(userRef.current?.id, activeSessionId);
|
||||
setSession(nextSession);
|
||||
setSessions((prev) => prependUnique(prev, nextSession));
|
||||
void loadSessionDetail(activeSessionId)
|
||||
.then(({ session: loaded }) => {
|
||||
setSession(loaded);
|
||||
setSessions((prev) => prependUnique(prev, loaded));
|
||||
})
|
||||
.catch(() => {});
|
||||
void ensureProvider(activeSessionId);
|
||||
void loadProjectMemory(activeSessionId, false);
|
||||
void refreshSessions();
|
||||
}
|
||||
subscribeToSession(activeSessionId);
|
||||
setChatState('streaming');
|
||||
} catch (err) {
|
||||
if (isAmbiguousReplySubmitError(err)) {
|
||||
if (activeSessionId && isAmbiguousReplySubmitError(err)) {
|
||||
subscribeToSession(activeSessionId);
|
||||
setChatState('streaming');
|
||||
setError(null);
|
||||
@@ -842,7 +874,7 @@ export function useTKMindChat(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (session) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
|
||||
if (session && activeSessionId) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
|
||||
if (err instanceof ApiError && err.status === 402) {
|
||||
notifyInsufficientBalance();
|
||||
} else {
|
||||
@@ -886,7 +918,7 @@ export function useTKMindChat(
|
||||
);
|
||||
|
||||
const newSession = useCallback(() => {
|
||||
if (chatState === 'streaming') return;
|
||||
if (chatState === 'streaming' || chatState === 'waiting') return;
|
||||
|
||||
connectTokenRef.current += 1;
|
||||
unsubscribeRef.current?.();
|
||||
@@ -902,12 +934,17 @@ export function useTKMindChat(
|
||||
}, [chatState]);
|
||||
|
||||
const refreshProjectMemory = useCallback(async () => {
|
||||
if (!session || chatState === 'streaming') return;
|
||||
if (!session || chatState === 'streaming' || chatState === 'waiting') return;
|
||||
await loadProjectMemory(session.id, true);
|
||||
}, [chatState, loadProjectMemory, session]);
|
||||
|
||||
const rememberCurrentContext = useCallback(async () => {
|
||||
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
|
||||
if (
|
||||
chatState === 'streaming' ||
|
||||
chatState === 'loading' ||
|
||||
chatState === 'connecting' ||
|
||||
chatState === 'waiting'
|
||||
) return;
|
||||
await rememberRecentContext({ silent: false });
|
||||
}, [chatState, rememberRecentContext]);
|
||||
|
||||
@@ -917,7 +954,7 @@ export function useTKMindChat(
|
||||
|
||||
const deleteSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
if (chatState === 'streaming') return;
|
||||
if (chatState === 'streaming' || chatState === 'waiting') return;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user