feat(agent): 聊天提交统一走 POST /agent/runs 异步网关

引入 Agent Run 网关替代直连 /sessions/:id/reply,并在 api_lockdown 白名单中放行新入口,避免策略拦截导致聊天不可用。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-29 22:50:00 +08:00
parent 16828a58f7
commit 8f620fa714
14 changed files with 784 additions and 89 deletions
+31 -4
View File
@@ -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
View File
@@ -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 {